mirror of
https://github.com/cline/cline.git
synced 2026-09-06 12:28:08 +08:00
Compare commits
143 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c97ca265f6 | |||
| ddf1921407 | |||
| 8ddd3f46f3 | |||
| ff1e3297a8 | |||
| 2428389620 | |||
| 6f8627bb5f | |||
| 1e81d98abf | |||
| 267170920a | |||
| 386c78c114 | |||
| 265a56391a | |||
| ff4bab22fb | |||
| bc468707a6 | |||
| 0a6a565d41 | |||
| d30e4d0194 | |||
| d453eed582 | |||
| 7e32314c0b | |||
| cce8f09ae5 | |||
| 0262e13ac4 | |||
| 6d2cf55fc5 | |||
| 3577c2efa9 | |||
| f97ef745d9 | |||
| 4e27e06670 | |||
| ef02d6b0b2 | |||
| 7ab6189595 | |||
| 4beaa2a086 | |||
| 5f90018ab5 | |||
| 9e761cd1f0 | |||
| e84f2ff962 | |||
| 1842254c57 | |||
| 4a2dad4552 | |||
| 3248c37358 | |||
| 172b46f1b0 | |||
| 9578d7cde1 | |||
| 2979d47e01 | |||
| 4569300f00 | |||
| 36e3f4cdd3 | |||
| bc5225ce52 | |||
| 50dc89b551 | |||
| d576b68cca | |||
| 6c2c0780ee | |||
| 021a014012 | |||
| ef1a68b5e3 | |||
| 72029fe205 | |||
| 2af151e736 | |||
| 64963c4e9c | |||
| 52571ccee8 | |||
| 87322feeb7 | |||
| 27f8372c5b | |||
| b3d3e9861f | |||
| 01178909ee | |||
| c982216113 | |||
| da6f705df2 | |||
| c7548a7f52 | |||
| e5f78a0456 | |||
| 4b5b090b29 | |||
| 559eba5dd1 | |||
| c013b4f329 | |||
| dcf91b081e | |||
| 9ab0cc7648 | |||
| add572cc12 | |||
| 307b92862b | |||
| 020ae3006e | |||
| c6d91be721 | |||
| 4d2fec787e | |||
| c8b05cdf9c | |||
| 9e69576fb0 | |||
| e6760ed6cc | |||
| 27a531c86a | |||
| 09c773bd5f | |||
| 037a781a6d | |||
| 968b46a179 | |||
| 5a777f28b6 | |||
| fe9e5bff74 | |||
| e65605535a | |||
| 3d100d835a | |||
| b9d3814355 | |||
| 12fbc48629 | |||
| d2f1e0cde0 | |||
| efe2388e4a | |||
| 247552fcb5 | |||
| 656e3276c6 | |||
| 0e29f05e28 | |||
| 985cb51c39 | |||
| abccde0e2a | |||
| ca984609ca | |||
| 6690d392cd | |||
| 40244f09fe | |||
| 9563a71c8a | |||
| c13e749eed | |||
| 8c3fd8ba55 | |||
| 8bfa7daa28 | |||
| 7cd4be7a68 | |||
| ab9f1a0785 | |||
| 68b84f3df4 | |||
| 16da0f1e06 | |||
| e898bd8825 | |||
| b7b0e96cc7 | |||
| f00c5f4ecc | |||
| 2709ccefcd | |||
| c8f0324536 | |||
| 937cebc7de | |||
| d4d5a49e67 | |||
| 01d3afe0c5 | |||
| 7bdab5613b | |||
| d79722bec0 | |||
| 06af720d85 | |||
| b80ef25a33 | |||
| 1996543eb0 | |||
| d9dfd57da5 | |||
| 890148407a | |||
| 8d6a948478 | |||
| 98e7fac400 | |||
| 090bddbcea | |||
| 5b41cf7af4 | |||
| a2f86bde9b | |||
| 176f591ba3 | |||
| 6f5ff1b407 | |||
| ae076e2506 | |||
| 046d674b85 | |||
| e41e80aaf5 | |||
| 14ed98ae87 | |||
| f8667f8a9d | |||
| 077561fb71 | |||
| 97fd1daec6 | |||
| 5c6e1d2ce9 | |||
| ec4d515d28 | |||
| 59efda3e22 | |||
| 936923d334 | |||
| 0a57ec3b7d | |||
| 670f3a1d62 | |||
| 8104f18f5a | |||
| 4de7790fa1 | |||
| adfb5a2b6e | |||
| d55a23448d | |||
| 1dd164f482 | |||
| 50b43c0559 | |||
| 943c52f0b3 | |||
| b84084936b | |||
| 014910deb9 | |||
| a30cefa595 | |||
| 620f402f36 | |||
| 22ff68565b | |||
| 3e1565da59 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Remove the clsx / tailwind merge dependencies and replace with template literals
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Migrate fetchUserCreditsData to protobus
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Ending grok 3 promo
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Migrate authStateChanged to protobus
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add taskId as metadata to use from LiteLLM
|
||||
@@ -0,0 +1,89 @@
|
||||
# Cline Protobuf Development Guide
|
||||
|
||||
This guide outlines how to add new gRPC endpoints for communication between the webview (frontend) and the extension host (backend).
|
||||
|
||||
## Overview
|
||||
|
||||
Cline uses [Protobuf](https://protobuf.dev/) to define a strongly-typed API, ensuring efficient and type-safe communication. All definitions are in the `/proto` directory. The compiler and plugins are included as project dependencies, so no manual installation is needed.
|
||||
|
||||
## Key Concepts & Best Practices
|
||||
|
||||
- **File Structure**: Each feature domain should have its own `.proto` file (e.g., `account.proto`, `task.proto`).
|
||||
- **Message Design**:
|
||||
- For simple, single-value data, use the shared types in `proto/common.proto` (e.g., `StringRequest`, `Empty`, `Int64Request`). This promotes consistency.
|
||||
- For complex data structures, define custom messages within the feature's `.proto` file (see `task.proto` for examples like `NewTaskRequest`).
|
||||
- **Naming Conventions**:
|
||||
- Services: `PascalCaseService` (e.g., `AccountService`).
|
||||
- RPCs: `camelCase` (e.g., `accountEmailIdentified`).
|
||||
- Messages: `PascalCase` (e.g., `StringRequest`).
|
||||
- **Streaming**: For server-to-client streaming, use the `stream` keyword on the response type. See `subscribeToAuthCallback` in `account.proto` for an example.
|
||||
|
||||
---
|
||||
|
||||
## 4-Step Development Workflow
|
||||
|
||||
Here’s how to add a new RPC, using `scrollToSettings` as an example.
|
||||
|
||||
### 1. Define the RPC in a `.proto` File
|
||||
|
||||
Add your service method to the appropriate file in the `proto/` directory.
|
||||
|
||||
**File: `proto/ui.proto`**
|
||||
```proto
|
||||
service UiService {
|
||||
// ... other RPCs
|
||||
// Scrolls to a specific settings section in the settings view
|
||||
rpc scrollToSettings(StringRequest) returns (KeyValuePair);
|
||||
}
|
||||
```
|
||||
Here, we use the common `StringRequest` and `KeyValuePair` types.
|
||||
|
||||
### 2. Compile Definitions
|
||||
|
||||
After editing a `.proto` file, regenerate the TypeScript code. From the project root, run:
|
||||
```bash
|
||||
npm run protos
|
||||
```
|
||||
This command compiles all `.proto` files and outputs the generated code to `src/generated/` and `src/shared/`. Do not edit these generated files manually.
|
||||
|
||||
### 3. Implement the Backend Handler
|
||||
|
||||
Create the RPC implementation in the backend. Handlers are located in `src/core/controller/[service-name]/`.
|
||||
|
||||
**File: `src/core/controller/ui/scrollToSettings.ts`**
|
||||
```typescript
|
||||
import { Controller } from ".."
|
||||
import { StringRequest, KeyValuePair } from "../../../shared/proto/common"
|
||||
|
||||
/**
|
||||
* Executes a scroll to settings action
|
||||
* @param controller The controller instance
|
||||
* @param request The request containing the ID of the settings section to scroll to
|
||||
* @returns KeyValuePair with action and value fields for the UI to process
|
||||
*/
|
||||
export async function scrollToSettings(controller: Controller, request: StringRequest): Promise<KeyValuePair> {
|
||||
return KeyValuePair.create({
|
||||
key: "scrollToSettings",
|
||||
value: request.value || "",
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Call the RPC from the Webview
|
||||
|
||||
Call the new RPC from a React component in `webview-ui/`. The generated client makes this simple.
|
||||
|
||||
**File: `webview-ui/src/components/browser/BrowserSettingsMenu.tsx`** (Example)
|
||||
```tsx
|
||||
import { UiServiceClient } from "../../../services/grpc"
|
||||
import { StringRequest } from "../../../../shared/proto/common"
|
||||
|
||||
// ... inside a React component
|
||||
const handleMenuClick = async () => {
|
||||
try {
|
||||
await UiServiceClient.scrollToSettings(StringRequest.create({ value: "browser" }))
|
||||
} catch (error) {
|
||||
console.error("Error scrolling to browser settings:", error)
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -22,6 +22,7 @@
|
||||
"react-hooks/exhaustive-deps": "off",
|
||||
"eslint-rules/no-protobuf-object-literals": "error",
|
||||
"eslint-rules/no-grpc-client-object-literals": "error",
|
||||
"eslint-rules/no-direct-vscode-api": "warn",
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
|
||||
+2
-1
@@ -7,6 +7,7 @@ tmp
|
||||
*.vsix
|
||||
|
||||
.DS_Store
|
||||
.idea
|
||||
|
||||
pnpm-lock.yaml
|
||||
|
||||
@@ -37,4 +38,4 @@ src/hosts/vscode/*/methods.ts
|
||||
src/hosts/vscode/*/index.ts
|
||||
src/hosts/vscode/client/host-grpc-client.ts
|
||||
src/hosts/vscode/host-grpc-service-config.ts
|
||||
src/standalone/server-setup.ts
|
||||
src/standalone/server-setup.ts
|
||||
|
||||
Vendored
+4
-3
@@ -23,19 +23,20 @@
|
||||
"request": "launch",
|
||||
"runtimeExecutable": "${execPath}",
|
||||
"args": [
|
||||
"--user-data-dir=${workspaceFolder}/dist/tmp/user",
|
||||
"--profile-temp",
|
||||
"--sync",
|
||||
"off",
|
||||
"--sync=off",
|
||||
"--disable-extensions",
|
||||
"--extensionDevelopmentPath=${workspaceFolder}",
|
||||
"${workspaceFolder}"
|
||||
],
|
||||
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
|
||||
"preLaunchTask": "clean-sandbox",
|
||||
"preLaunchTask": "clean-tmp-user",
|
||||
"internalConsoleOptions": "openOnSessionStart",
|
||||
"postDebugTask": "stop",
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"TEMP_PROFILE": "true",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}"
|
||||
}
|
||||
},
|
||||
|
||||
Vendored
+2
-2
@@ -233,10 +233,10 @@
|
||||
"type": "shell"
|
||||
},
|
||||
{
|
||||
"label": "clean-sandbox",
|
||||
"label": "clean-tmp-user",
|
||||
"type": "shell",
|
||||
"dependsOn": ["watch"],
|
||||
"command": "rm -rf .vscode-dev"
|
||||
"command": "rm -rf ${workspaceFolder}/dist/tmp/user && mkdir -p ${workspaceFolder}/dist/tmp/user"
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
|
||||
+4
-4
@@ -2,8 +2,10 @@
|
||||
.vscode/**
|
||||
.vscode-test/**
|
||||
out/**
|
||||
dist-standalone/**
|
||||
node_modules/**
|
||||
src/**
|
||||
standalone/**
|
||||
.gitignore
|
||||
.yarnrc
|
||||
esbuild.js
|
||||
@@ -13,6 +15,7 @@ vsc-extension-quickstart.md
|
||||
**/*.map
|
||||
**/*.ts
|
||||
**/.vscode-test.*
|
||||
eslint-rules/**
|
||||
|
||||
# Custom
|
||||
demo.gif
|
||||
@@ -32,15 +35,12 @@ webview-ui/node_modules/**
|
||||
|
||||
# Ignore docs
|
||||
docs/**
|
||||
old_docs/**
|
||||
|
||||
# Fix issue where codicons don't get packaged (https://github.com/microsoft/vscode-extension-samples/issues/692)
|
||||
!node_modules/@vscode/codicons/dist/codicon.css
|
||||
!node_modules/@vscode/codicons/dist/codicon.ttf
|
||||
|
||||
# Include KaTeX CSS and fonts for LaTeX rendering
|
||||
!webview-ui/node_modules/katex/dist/katex.min.css
|
||||
!webview-ui/node_modules/katex/dist/fonts/**
|
||||
|
||||
# Include default themes JSON files used in getTheme
|
||||
!src/integrations/theme/default-themes/**
|
||||
|
||||
|
||||
@@ -1,5 +1,56 @@
|
||||
# Changelog
|
||||
|
||||
## [3.18.6]
|
||||
|
||||
- Update request header to include `"ai-client-type": "Cline"` to SAP Api Provider
|
||||
- Add organization organization accounts
|
||||
|
||||
## [3.18.5]
|
||||
|
||||
- Fix Plan/Act mode persistence across sessions and multi-workspace conflicts
|
||||
- Improve provider switching performance by 18x (from 550ms to 30ms) with batched storage operations
|
||||
- Improve SAP AI Core provider model organization and fix exception handling (Thanks @schardosin!)
|
||||
|
||||
## [3.18.4]
|
||||
|
||||
- Add support for Gemini 2.5 Pro and Flash to SAP AI Core Provider
|
||||
- Fix logging in with Cline account not getting past welcome screen
|
||||
|
||||
## [3.18.3]
|
||||
|
||||
- Improve Cerebras Qwen model performance by removing thinking tokens from model input (Thanks @kevint-cerebras!)
|
||||
- Improve Claude Code provider with better error handling and performance optimizations (Thanks @BarreiroT!)
|
||||
|
||||
## [3.18.2]
|
||||
|
||||
- Fix issue where terminal output would not be captured if shell integration fails by falling back to capturing the terminal content.
|
||||
- Add confirmation popup when deleting tasks
|
||||
- Add support for Claude Sonnet 4 and Opus 4 model in SAP AI Core provider (Thanks @lizzzcai!)
|
||||
- Add support for `litellm_session_id` to group requests in a single session (Thanks @jorgegarciarey!)
|
||||
- Add "Thinking Budget" customization for Claude Code (Thanks @BarreiroT!)
|
||||
- Fix issue where the extension would use the user's environment variables for authentication when using Claude Code (Thanks @BarreiroT!)
|
||||
|
||||
## [3.18.1]
|
||||
|
||||
- Add support for Claude 4 Sonnet in SAP AI Core provider (Thanks @GTxx!)
|
||||
- Fix ENAMETOOLONG error when using Claude Code provider with long conversation histories (Thanks @BarreiroT!)
|
||||
- Remove Gemini CLI provider because Google asked us to
|
||||
- Fix bug with "Delete All Tasks" functionality
|
||||
|
||||
## [3.18.0]
|
||||
|
||||
- Optimized Cline to work with the Claude 4 family of models, resulting in improved performance, reliability, and new capabilities
|
||||
- Added a new Gemini CLI provider that allows you to use your local Gemini CLI authentication to access Gemini models for free (Thanks @google-gemini!)
|
||||
- Optimized Cline to work with the Gemini 2.5 family of models
|
||||
- Updated the default and recommended model to Claude 4 Sonnet for the best performance
|
||||
- Fix race condition in Plan/Act mode switching
|
||||
- Improve robustness of search and replace parsing
|
||||
|
||||
## [3.17.16]
|
||||
|
||||
- Fix Claude Code provider error handling for incomplete messages during long-running tasks (Thanks @BarreiroT!)
|
||||
- Add taskId as metadata to LiteLLM API requests for better request tracing (Thanks @jorgegarciarey!)
|
||||
|
||||
## [3.17.15]
|
||||
|
||||
- Fix LiteLLM provider to properly respect selected model IDs when switching between Plan and Act modes (Thanks @sammcj!)
|
||||
|
||||
@@ -30,7 +30,7 @@ English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Meet Cline, an AI assistant that can use your **CLI** a**N**d **E**ditor.
|
||||
Meet Cline (pronounced /klaɪn/, like "Klein"), an AI assistant that can use your **CLI** a**N**d **E**ditor.
|
||||
|
||||
Thanks to [Claude 3.7 Sonnet's agentic coding capabilities](https://www.anthropic.com/claude/sonnet), Cline can handle complex software development tasks step-by-step. With tools that let him create & edit files, explore large projects, use the browser, and execute terminal commands (after you grant permission), he can assist you in ways that go beyond code completion or tech support. Cline can even use the Model Context Protocol (MCP) to create new tools and extend his own capabilities. While autonomous AI scripts traditionally run in sandboxed environments, this extension provides a human-in-the-loop GUI to approve every file change and terminal command, providing a safe and accessible way to explore the potential of agentic AI.
|
||||
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
"group": "Provider Configuration",
|
||||
"pages": [
|
||||
"provider-config/anthropic",
|
||||
"provider-config/claude-code",
|
||||
"provider-config/aws-bedrock-with-credentials-authentication",
|
||||
"provider-config/aws-bedrock-with-profile-authentication",
|
||||
"provider-config/gcp-vertex-ai",
|
||||
@@ -169,6 +170,10 @@
|
||||
"running-models-locally/ollama"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Troubleshooting",
|
||||
"pages": ["troubleshooting/terminal-quick-fixes", "troubleshooting/terminal-integration-guide"]
|
||||
},
|
||||
{
|
||||
"group": "More Info",
|
||||
"pages": ["more-info/telemetry"]
|
||||
|
||||
@@ -120,7 +120,7 @@ Example of context window usage over 50% with a 200K context window:
|
||||
# Context Window Usage
|
||||
|
||||
105,000 / 200,000 tokens (53%)
|
||||
Model: anthropic/claude-3.7-sonnet (200K context window)
|
||||
Model: anthropic/claude-sonnet-4 (200K context window)
|
||||
\`\`\`
|
||||
|
||||
**IMPORTANT**: When you see context window usage at or above 50%, you MUST:
|
||||
|
||||
@@ -58,3 +58,16 @@ When you use the terminal mention in your message, here's what happens behind th
|
||||
6. The AI can now "see" the complete terminal output with all formatting preserved
|
||||
|
||||
This process happens automatically whenever you use the terminal mention, giving the AI access to your command results, error messages, and other terminal output without you having to copy it manually.
|
||||
|
||||
## Troubleshooting Terminal Issues
|
||||
|
||||
If you're experiencing issues with terminal mentions or terminal integration in general (such as "Shell Integration Unavailable" or commands not showing output), please refer to our comprehensive [Terminal Integration Troubleshooting Guide](/troubleshooting/terminal-integration-guide).
|
||||
|
||||
Common issues include:
|
||||
|
||||
- Terminal mentions not capturing output
|
||||
- "Shell Integration Unavailable" messages in Cline chat
|
||||
- Commands executing but output not visible to Cline
|
||||
- Terminal integration working inconsistently
|
||||
|
||||
The troubleshooting guide provides platform-specific solutions and detailed configuration steps to resolve these issues.
|
||||
|
||||
@@ -74,8 +74,25 @@ This approach ensures that all terminal output, including colors and formatting,
|
||||
|
||||
- **Select specific output when needed**: By default, the integration captures all terminal content, but you can also select specific lines before right-clicking to focus on just the relevant output.
|
||||
|
||||
- **Combine with file mentions**: After sending terminal output to Cline, you can enhance your question by mentioning relevant files using the @ mentions feature.
|
||||
- **Combine terminal outputs with file mentions**: After sending terminal output to Cline, you can enhance your question by mentioning relevant files using the @ mentions feature.
|
||||
|
||||
- **Use for build and test output**: Terminal integration is particularly useful for understanding complex build errors or test failures that span multiple lines.
|
||||
- **Contextualize build & test outputs with the terminal**: Terminal integration is particularly useful for understanding complex build errors or test failures that span multiple lines.
|
||||
|
||||
Next time you're staring at a cryptic error message in your terminal, try using Cline's terminal integration instead of copying and pasting. You'll get more accurate help because Cline can see the complete terminal context with proper formatting.
|
||||
|
||||
## Troubleshooting Terminal Issues
|
||||
|
||||
If you're experiencing issues with terminal integration, such as "Shell Integration Unavailable" or commands not showing output, please refer to our comprehensive [Terminal Integration Troubleshooting Guide](/troubleshooting/terminal-integration-guide).
|
||||
|
||||
The troubleshooting guide covers:
|
||||
|
||||
- Common terminal integration issues and quick fixes
|
||||
- Platform-specific solutions for Windows, macOS, and Linux
|
||||
- Shell-specific configurations for zsh, bash, PowerShell, and more
|
||||
- Advanced debugging techniques
|
||||
- Terminal settings optimization
|
||||
|
||||
<Tip>
|
||||
**Quick Fix**: Most terminal issues can be resolved by switching to bash in the Cline settings and increasing the shell
|
||||
integration timeout to 10 seconds.
|
||||
</Tip>
|
||||
|
||||
@@ -6,7 +6,7 @@ title: "Telemetry"
|
||||
|
||||
To help make Cline better for everyone, we collect anonymous usage data that helps us understand how developers are using our open-source AI coding agent. This feedback loop is crucial for improving Cline's capabilities and user experience.
|
||||
|
||||
We use PostHog, an open-source analytics platform, for data collection and analysis. Our telemetry implementation is fully transparent - you can review the [source code](https://github.com/cline/cline/blob/main/src/services/telemetry/TelemetryService.ts) to see exactly what we track.
|
||||
We use PostHog, an open-source analytics platform, for data collection and analysis. Our telemetry implementation is fully transparent - you can review the [source code](https://github.com/cline/cline/blob/main/src/services/posthog/telemetry/TelemetryService.ts) to see exactly what we track.
|
||||
|
||||
### Tracking Policy
|
||||
|
||||
@@ -22,7 +22,7 @@ We collect basic anonymous usage data including:
|
||||
**System Context:** OS type and VS Code environment details\
|
||||
**UI Activity:** Navigation patterns and feature usage
|
||||
|
||||
For complete transparency, you can inspect our [telemetry implementation](https://github.com/cline/cline/blob/main/src/services/telemetry/TelemetryService.ts) to see the exact events we track.
|
||||
For complete transparency, you can inspect our [telemetry implementation](https://github.com/cline/cline/blob/main/src/services/posthog/telemetry/TelemetryService.ts) to see the exact events we track.
|
||||
|
||||
### How to Opt Out
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
title: "Claude Code"
|
||||
description: "Use your Claude Max subscription with Cline instead of paying per token. Learn how to set up and configure the Claude Code provider."
|
||||
---
|
||||
|
||||
**Website:** [https://docs.anthropic.com/en/docs/claude-code/setup](https://docs.anthropic.com/en/docs/claude-code/setup)
|
||||
|
||||
The Claude Code provider lets you use your existing Claude subscription with Cline. If you have Claude Max, this means you can use Claude in Cline without paying extra API costs.
|
||||
|
||||
<Frame>
|
||||
<img
|
||||
src="https://storage.googleapis.com/cline_public_images/docs/assets/claude-code-use-opus.gif"
|
||||
alt="Using the Claude Code provider in Cline with Opus model"
|
||||
/>
|
||||
</Frame>
|
||||
|
||||
## Setup
|
||||
|
||||
First, you'll need to install and authenticate Claude Code on your system:
|
||||
|
||||
1. **Install Claude Code**: Follow Anthropic's [official setup guide](https://docs.anthropic.com/en/docs/claude-code/setup) to install and authenticate the Claude CLI.
|
||||
|
||||
2. **Configure in Cline**:
|
||||
- Open Cline settings (⚙️ icon)
|
||||
- Select **Claude Code** from the **API Provider** dropdown
|
||||
- Set the path to your Claude CLI executable (usually just `claude` if it's in your PATH)
|
||||
|
||||
<Frame>
|
||||
<img
|
||||
src="https://storage.googleapis.com/cline_public_images/docs/assets/claude-code-setup.gif"
|
||||
alt="Setting up the Claude Code provider in Cline"
|
||||
/>
|
||||
</Frame>
|
||||
|
||||
### Finding your Claude Code path
|
||||
|
||||
If you're not sure where Claude Code is installed:
|
||||
|
||||
- **macOS / Linux**: Run `which claude` in your terminal
|
||||
- **Windows (Command Prompt)**: Run `where claude`
|
||||
- **Windows (PowerShell)**: Run `Get-Command claude`
|
||||
|
||||
## Supported Models
|
||||
|
||||
The Claude Code provider supports these models:
|
||||
|
||||
- `claude-sonnet-4-20250514` (Recommended)
|
||||
- `claude-opus-4-20250514`
|
||||
- `claude-3-7-sonnet-20250219`
|
||||
- `claude-3-5-sonnet-20241022`
|
||||
- `claude-3-5-haiku-20241022`
|
||||
|
||||
## How it works
|
||||
|
||||
When you use Claude Code with Cline, here's what happens behind the scenes:
|
||||
|
||||
Cline wraps the Claude Code CLI to handle your requests. Each time you send a message, Cline starts a new `claude` process, sends your conversation, and streams the response back. The AI reasoning comes from Claude Code, but all the actual file editing, terminal commands, and other tools are handled by Cline.
|
||||
|
||||
The main difference you'll notice is that responses don't stream character-by-character like other providers. Instead, Claude Code processes your full request before sending back the complete response.
|
||||
|
||||
## Limitations
|
||||
|
||||
There are a few things to keep in mind with Claude Code:
|
||||
|
||||
- Images in your messages get converted to text placeholders since Claude Code doesn't support image uploads through the CLI
|
||||
- Prompt caching isn't available with this provider
|
||||
- Responses don't stream in real-time like other providers
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you run into issues:
|
||||
|
||||
**Authentication problems**: Make sure you're logged into Claude Code with your subscription account. Run `claude auth status` to check.
|
||||
|
||||
**Path issues**: Double-check that the Claude CLI path in Cline's settings is correct. Try running `claude --version` in your terminal to verify it's working.
|
||||
|
||||
**Still having trouble?** We're actively improving this integration. Report issues on our [GitHub](https://github.com/cline/cline/issues) or ask for help in our [Discord](https://discord.gg/cline).
|
||||
|
||||
## Usage with subscriptions
|
||||
|
||||
If you have a Claude Max subscription, your usage in Cline shows up as $0.00 in the billing interface since you're not paying additional API costs. Your usage still counts against your subscription limits, but you won't see per-token charges.
|
||||
|
||||
For more details about using Claude Code with your subscription, check out Anthropic's documentation:
|
||||
|
||||
- [Claude Code Setup Guide](https://docs.anthropic.com/en/docs/claude-code/setup)
|
||||
- [Using Claude Code with Pro/Max Plans](https://support.anthropic.com/en/articles/11145838-using-claude-code-with-your-pro-or-max-plan)
|
||||
@@ -0,0 +1,399 @@
|
||||
---
|
||||
title: "Terminal Integration Troubleshooting Guide"
|
||||
sidebarTitle: "Terminal Troubleshooting"
|
||||
description: "Complete guide to resolving terminal integration issues in Cline"
|
||||
---
|
||||
|
||||
This guide helps you resolve terminal integration issues in Cline. Terminal integration is crucial for Cline to execute commands and read their output, enabling it to understand errors, test results, and command responses.
|
||||
|
||||
<Tip>
|
||||
If you're experiencing terminal issues, try switching to a simpler shell like `bash` in the Cline settings, under "Terminal Settings"
|
||||
|
||||
This resolves most terminal integration problems.
|
||||
|
||||
</Tip>
|
||||
|
||||
## Quick Diagnosis Flowchart
|
||||
|
||||
Follow this flowchart to quickly identify your issue:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Terminal Issue] --> B{Can Cline execute commands?}
|
||||
B -->|No| C[Shell Integration Unavailable]
|
||||
B -->|Yes| D{Can Cline see the output?}
|
||||
D -->|No| E[Output Capture Failed]
|
||||
D -->|Yes| F{Is the output corrupted?}
|
||||
F -->|Yes| G[Character Filtering Issue]
|
||||
F -->|No| H{Does the command hang?}
|
||||
H -->|Yes| I[Long-Running Command Issue]
|
||||
H -->|No| J[Check Terminal Settings]
|
||||
|
||||
C --> K[Try Solution 1]
|
||||
E --> L[Try Solution 2]
|
||||
G --> M[Try Solution 3]
|
||||
I --> N[Try Solution 4]
|
||||
|
||||
style A fill:#f9f,stroke:#333,stroke-width:2px
|
||||
style K fill:#9f9,stroke:#333,stroke-width:2px
|
||||
style L fill:#9f9,stroke:#333,stroke-width:2px
|
||||
style M fill:#9f9,stroke:#333,stroke-width:2px
|
||||
style N fill:#9f9,stroke:#333,stroke-width:2px
|
||||
```
|
||||
|
||||
## Common Issues & Quick Solutions
|
||||
|
||||
### 1. Shell Integration Unavailable
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Message: "Shell Integration Unavailable"
|
||||
- Commands execute but Cline can't read output
|
||||
- Terminal works fine manually but not with Cline
|
||||
|
||||
**Quick Solutions:**
|
||||
|
||||
#### macOS
|
||||
|
||||
- **Switch to bash**
|
||||
|
||||
1. Go to Cline Settings
|
||||
2. Left-Click the **"Terminal Settings"** tab
|
||||
3. Navigate to **"Default Terminal Profile"** and select **"bash"** from the drop-down menu
|
||||
|
||||
- **Disable Oh-My-Zsh temporarily**:
|
||||
|
||||
1. If using zsh, enter `mv ~/.zshrc ~/.zshrc.backup` into the terminal
|
||||
2. Restart VSCode
|
||||
|
||||
- **Set environment**:
|
||||
1.a For Zsh users, use one of the following Zsh commands to edit your shell profile:
|
||||
|
||||
- `nano ~/.zshrc`
|
||||
- `vim ~/.zshrc`
|
||||
- `code ~/.zshrc`
|
||||
|
||||
1.b For Bash users
|
||||
|
||||
- nano ~/.bash_profile
|
||||
|
||||
2. Add the following to your shell config: `export TERM=xterm-256color`
|
||||
3. Save your configuration
|
||||
|
||||
#### Windows
|
||||
|
||||
- **Use PowerShell 7**
|
||||
|
||||
1. Install from Microsoft Store
|
||||
2. Go to Cline Settings
|
||||
3. Left-Click the **"Terminal Settings"** tab
|
||||
4. Navigate to **"Default Terminal Profile"** and select **"PowerShell 7"** from the drop-down menu
|
||||
|
||||
- **Disable Windows ConPTY**
|
||||
|
||||
1. Navigate to your VSCode Settings
|
||||
2. Enter "Integrated: Windows Enable Conpty" into the Settings searchbar
|
||||
3. Uncheck the option
|
||||
|
||||
- **Try Command Prompt**
|
||||
1. Go to Cline Settings
|
||||
2. Left-Click the **"Terminal Settings"** tab
|
||||
3. Navigate to **"Default Terminal Profile"** and select **"Command Prompt"** from the drop-down menu
|
||||
|
||||
#### Linux
|
||||
|
||||
- **Use bash**
|
||||
|
||||
1. Go to Cline Settings
|
||||
2. Left-Click the **"Terminal Settings"** tab
|
||||
3. Navigate to **"Default Terminal Profile"** and select **"bash"** from the drop-down menu
|
||||
|
||||
- **Check permissions**
|
||||
|
||||
1. Ensure VSCode has terminal access permissions
|
||||
|
||||
- **Disable custom prompts**
|
||||
1. Comment out prompt customizations in `.bashrc`
|
||||
|
||||
### 2. Command Output Not Visible
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Cline states in chat: "[Command is running but producing no output]"
|
||||
- Commands complete but Cline doesn't see results
|
||||
- Commands work sometimes but not consistently
|
||||
|
||||
**Solutions:**
|
||||
|
||||
- **Increase Shell Integration Timeout**
|
||||
|
||||
1. Within Cline, left-click the **Settings** button in the top right-hand corner of the chat window
|
||||
2. Once in the **Settings** window, left-click the **"Terminal Settings"** tab from the left-hand column
|
||||
3. Navigate to "Shell integration timeout (seconds)" and enter **"10"** into the text field
|
||||
|
||||
- **Disable Terminal Reuse**
|
||||
|
||||
1. Within Cline, left-click the **Settings** button in the top right-hand corner of the chat window
|
||||
2. Once in the **Settings** window, left-click the **"Terminal Settings"** tab from the left-hand column
|
||||
3. Look for **"Enable aggressive terminal reuse"**, and **uncheck** this option
|
||||
|
||||
- **Check for interfering extensions**
|
||||
1. Disable other terminal-related VSCode extensions
|
||||
|
||||
### 3. Character Filtering Issues
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Commas missing from output (JSON appears corrupted)
|
||||
- Special characters stripped from terminal output
|
||||
- Syntax errors that don't appear when running manually
|
||||
|
||||
**Solution:**
|
||||
This is a known bug in output processing. Workarounds:
|
||||
|
||||
- Recommend AI to use file output instead
|
||||
1. Tell Cline in chat or Cline rules, to use `command > output.txt` before reading the file/s
|
||||
|
||||
<Tip>
|
||||
This family of issues is only partially solved in the latest Cline versions, so if you still face this, create a GitHub issue
|
||||
if it is a persistent problem.
|
||||
</Tip>
|
||||
|
||||
### 4. Long-Running Commands & Progress Bars
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Docker builds never complete in Cline
|
||||
- Progress bars consume thousands of tokens
|
||||
- The Cline button "Proceed while running" doesn't work properly in chat
|
||||
|
||||
<Tip>
|
||||
This family of issues has been solved in latest Cline versions but if you still face any issues, then create a GitHub issue
|
||||
for this.
|
||||
</Tip>
|
||||
|
||||
## Terminal Settings Explained
|
||||
|
||||
Access these in Cline by clicking the settings icon, and navigating to the "Terminal Settings" section:
|
||||
|
||||
### Default Terminal Profile
|
||||
|
||||
- **What it does**: Selects which shell Cline uses for commands
|
||||
- **When to change**: If experiencing shell integration issues with your default shell
|
||||
- **Recommended**: - macOS: bash (if zsh has issues) - Windows: PowerShell 7 - Linux: bash
|
||||
|
||||
### Shell Integration Timeout
|
||||
|
||||
- **What it does**: How long Cline waits for the terminal to be ready
|
||||
- **Default**: 4 seconds
|
||||
- **When to increase**:
|
||||
- Slow shell startup (heavy .zshrc/.bashrc)
|
||||
- WSL environments
|
||||
- SSH connections
|
||||
- **Recommended**: - Start with 10 seconds if having issues
|
||||
|
||||
### Enable Aggressive Terminal Reuse
|
||||
|
||||
- **What it does**: Reuses existing terminals even if not in the correct directory
|
||||
- **When to disable**:
|
||||
- Commands execute in wrong directory
|
||||
- Virtual environment issues
|
||||
- Terminal state corruption
|
||||
- **Trade-off**: - Disabling creates more terminals but ensures clean state
|
||||
|
||||
### Terminal Output Line Limit
|
||||
|
||||
- **What it does**: Limits how many lines Cline reads from terminal output
|
||||
- **Default**: 500 lines
|
||||
- **When to adjust**:
|
||||
- Increase for verbose build outputs
|
||||
- Decrease if hitting token limits
|
||||
- Set to 100 for commands with progress bars
|
||||
|
||||
## Platform-Specific Solutions
|
||||
|
||||
### macOS Issues
|
||||
|
||||
#### Oh-My-Zsh Conflicts
|
||||
|
||||
Oh-My-Zsh often interferes with shell integration. Solutions:
|
||||
|
||||
1. Create a minimal `.zshrc` for VSCode:
|
||||
```bash
|
||||
# ~/.zshrc-vscode
|
||||
export TERM=xterm-256color
|
||||
export PAGER=cat
|
||||
# Minimal PATH and environment setup
|
||||
```
|
||||
2. Configure VSCode to use it:
|
||||
```json
|
||||
{
|
||||
"terminal.integrated.env.osx": {
|
||||
"ZDOTDIR": "~/.zshrc-vscode"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### macOS 15+ Issues
|
||||
|
||||
Recent macOS versions have stricter terminal permissions:
|
||||
|
||||
1. System Preferences → Privacy & Security → Developer Tools
|
||||
2. Add Visual Studio Code
|
||||
3. Restart VSCode completely
|
||||
|
||||
### Windows Issues
|
||||
|
||||
#### PowerShell Execution Policy
|
||||
|
||||
If commands fail silently:
|
||||
|
||||
```powershell
|
||||
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||
```
|
||||
|
||||
#### WSL Integration
|
||||
|
||||
For WSL issues:
|
||||
|
||||
1. Use WSL extension for VSCode
|
||||
2. Open folder in WSL: `code .` from WSL terminal
|
||||
3. Select "WSL Bash" as terminal profile in Cline
|
||||
|
||||
#### Path Issues
|
||||
|
||||
Windows path problems:
|
||||
|
||||
1. Use forward slashes in Cline: `C:/Users/...`
|
||||
2. Quote paths with spaces: `"C:/Program Files/..."`
|
||||
3. Avoid `~` - use full paths
|
||||
|
||||
### Linux/SSH/Container Issues
|
||||
|
||||
#### SSH Connections
|
||||
|
||||
For remote development:
|
||||
|
||||
1. Install Cline on the remote machine, not locally
|
||||
2. Use SSH extension's integrated terminal
|
||||
3. Increase timeout to 15+ seconds
|
||||
|
||||
#### Docker Containers
|
||||
|
||||
When developing in containers:
|
||||
|
||||
1. Install Cline in the container
|
||||
2. Use Dev Containers extension
|
||||
3. Ensure shell integration scripts are available
|
||||
|
||||
## Shell-Specific Fixes
|
||||
|
||||
### Zsh
|
||||
|
||||
```bash
|
||||
# Add to ~/.zshrc
|
||||
export TERM=xterm-256color
|
||||
export PAGER=cat
|
||||
# Disable fancy prompts for VSCode
|
||||
if [[ "$TERM_PROGRAM" == "vscode" ]]; then
|
||||
PS1="%n@%m %1~ %# "
|
||||
fi
|
||||
```
|
||||
|
||||
### Bash
|
||||
|
||||
```bash
|
||||
# Add to ~/.bashrc
|
||||
export TERM=xterm-256color
|
||||
export PAGER=cat
|
||||
# Simple prompt for VSCode
|
||||
if [[ "$TERM_PROGRAM" == "vscode" ]]; then
|
||||
PS1='\u@\h:\w\$ '
|
||||
fi
|
||||
```
|
||||
|
||||
### Fish
|
||||
|
||||
```fish
|
||||
# Add to ~/.config/fish/config.fish
|
||||
set -x TERM xterm-256color
|
||||
set -x PAGER cat
|
||||
# Disable fancy features in VSCode
|
||||
if test "$TERM_PROGRAM" = "vscode"
|
||||
function fish_prompt
|
||||
echo (whoami)'@'(hostname)':'(pwd)'> '
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### PowerShell
|
||||
|
||||
```powershell
|
||||
# Add to $PROFILE
|
||||
$env:PAGER = "cat"
|
||||
# Disable progress bars
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
```
|
||||
|
||||
## Advanced Troubleshooting
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable terminal debugging to see what's happening:
|
||||
|
||||
1. Open VSCode Command Palette (Cmd/Ctrl+Shift+P)
|
||||
2. Run: "Developer: Set Log Level..."
|
||||
3. Choose "Trace"
|
||||
4. Check Output panel → "Cline" for terminal logs
|
||||
|
||||
### Manual Shell Integration Test
|
||||
|
||||
Test if shell integration works at all:
|
||||
|
||||
```bash
|
||||
# In VSCode terminal
|
||||
echo $TERM_PROGRAM # Should show "vscode"
|
||||
echo $VSCODE_SHELL_INTEGRATION # Should be "1"
|
||||
```
|
||||
|
||||
## FAQ
|
||||
|
||||
### Why does Cline create so many terminals?
|
||||
|
||||
When shell integration fails, Cline can't reuse terminals safely (they might be running long processes). Enable shell integration or adjust the terminal reuse setting.
|
||||
|
||||
### Can I use my custom shell (nushell, xonsh, etc.)?
|
||||
|
||||
Cline officially supports bash, zsh, fish, and PowerShell. Custom shells may work but aren't guaranteed. Use bash as a fallback.
|
||||
|
||||
### Why do some commands work but others don't?
|
||||
|
||||
Commands that use interactive features (pagers, progress bars, curses) often fail. Set `PAGER=cat` and use non-interactive flags.
|
||||
|
||||
### How do I know if shell integration is working?
|
||||
|
||||
Working integration shows command output in Cline's chat. Failed integration shows "Shell Integration Unavailable" or "[Command is running but producing no output]".
|
||||
|
||||
## Still Having Issues?
|
||||
|
||||
If you've tried everything:
|
||||
|
||||
1. **Collect Debug Info**:
|
||||
|
||||
```bash
|
||||
echo "Shell: $SHELL"
|
||||
echo "Term: $TERM"
|
||||
echo "VSCode: $TERM_PROGRAM"
|
||||
which bash
|
||||
bash --version
|
||||
```
|
||||
|
||||
2. **Report the Issue**:
|
||||
- Use `/reportbug` in Cline github issues
|
||||
- Include your debug info
|
||||
- Mention which solutions you tried
|
||||
|
||||
<Tip>
|
||||
Remember: Most terminal issues are resolved by switching to bash and increasing the timeout. Start there before trying complex
|
||||
solutions.
|
||||
</Tip>
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
title: "Terminal Quick Fixes"
|
||||
sidebarTitle: "Terminal Quick Fixes"
|
||||
description: "Quick solutions for common terminal issues"
|
||||
---
|
||||
|
||||
**Here is a list of common fixes, starting with the most applicable:**
|
||||
|
||||
- **Switch to bash** (solves most instances)
|
||||
|
||||
1. Within Cline, left-click the **Settings** button in the top right-hand corner of the chat window
|
||||
2. Once in the **Settings** window, left-click the **"Terminal Settings"** tab from the left-hand column
|
||||
3. Navigate to **"Default Terminal Profile"** and select **"bash"** from the drop-down
|
||||
|
||||
- **Increase timeout**
|
||||
|
||||
1. Within Cline, left-click the **Settings** button in the top right-hand corner of the chat window
|
||||
2. Once in the **Settings** window, left-click the **"Terminal Settings"** tab from the left-hand column
|
||||
3. Navigate to "Shell integration timeout (seconds)" and enter **"10"** into the text field
|
||||
|
||||
- **Disable terminal reuse**
|
||||
1. Within Cline, left-click the **Settings** button in the top right-hand corner of the chat window
|
||||
2. Once in the **Settings** window, left-click the **"Terminal Settings"** tab from the left-hand column
|
||||
3. Look for **"Enable aggressive terminal reuse"**, and **uncheck** this option
|
||||
|
||||
## Platform-Specific Fixes
|
||||
|
||||
### macOS + Oh-My-Zsh
|
||||
|
||||
```bash
|
||||
# Create minimal config for VSCode
|
||||
echo 'export TERM=xterm-256color' > ~/.zshrc-vscode
|
||||
echo 'export PAGER=cat' >> ~/.zshrc-vscode
|
||||
```
|
||||
|
||||
### Windows PowerShell
|
||||
|
||||
```powershell
|
||||
# Run as Administrator
|
||||
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||
```
|
||||
|
||||
### WSL
|
||||
|
||||
- Open folder from WSL: `code .`
|
||||
- Select **"WSL Bash"** in Cline settings, under **"Terminal Settings"**
|
||||
- Increase **"Shell integration timeout (seconds)"** to **15**
|
||||
|
||||
## Full Guide
|
||||
|
||||
For detailed troubleshooting, see the [Complete Terminal Troubleshooting Guide](/troubleshooting/terminal-integration-guide).
|
||||
@@ -0,0 +1,123 @@
|
||||
const { RuleTester: DirectApiRuleTester } = require("eslint")
|
||||
const noDirectVscodeApiRule = require("../no-direct-vscode-api")
|
||||
|
||||
const directApiRuleTester = new DirectApiRuleTester({
|
||||
parser: require.resolve("@typescript-eslint/parser"),
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: "module",
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
directApiRuleTester.run("no-direct-vscode-api", noDirectVscodeApiRule, {
|
||||
valid: [
|
||||
// Should allow vscode.postMessage in grpc-client-base.ts
|
||||
{
|
||||
code: `vscode.postMessage({ type: "grpc_request", data: {} })`,
|
||||
filename: "grpc-client-base.ts",
|
||||
},
|
||||
{
|
||||
code: `vscode.postMessage({ type: "grpc_request_cancel" })`,
|
||||
filename: "/path/to/grpc-client-base.ts",
|
||||
},
|
||||
// Should allow in exception directories
|
||||
{
|
||||
code: `vscode.workspace.workspaceFolders`,
|
||||
filename: "/src/hosts/vscode/host-bridge.ts",
|
||||
},
|
||||
{
|
||||
code: `vscode.workspace.fs.stat(uri)`,
|
||||
filename: "/standalone/runtime-files/helpers.ts",
|
||||
},
|
||||
// Should allow other vscode API calls
|
||||
{
|
||||
code: `vscode.window.showInformationMessage("Hello")`,
|
||||
filename: "test.ts",
|
||||
},
|
||||
// Should allow postMessage calls on other objects
|
||||
{
|
||||
code: `window.postMessage({ type: "test" }, "*")`,
|
||||
filename: "test.ts",
|
||||
},
|
||||
// Should allow variables named vscode but not calling postMessage
|
||||
{
|
||||
code: `const vscode = { other: "method" }; vscode.other()`,
|
||||
filename: "test.ts",
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
// Should disallow vscode.postMessage in regular files
|
||||
{
|
||||
code: `vscode.postMessage({ type: "test", data: {} })`,
|
||||
filename: "test.ts",
|
||||
errors: [
|
||||
{
|
||||
messageId: "useGrpcClient",
|
||||
},
|
||||
],
|
||||
},
|
||||
// Should disallow vscode.postMessage in components
|
||||
{
|
||||
code: `vscode.postMessage({ type: "apiConfiguration", apiConfiguration })`,
|
||||
filename: "ApiOptions.tsx",
|
||||
errors: [
|
||||
{
|
||||
messageId: "useGrpcClient",
|
||||
},
|
||||
],
|
||||
},
|
||||
// Should disallow vscode.postMessage in test files
|
||||
{
|
||||
code: `vscode.postMessage({ type: "newTask", text: message.text })`,
|
||||
filename: "test.test.ts",
|
||||
errors: [
|
||||
{
|
||||
messageId: "useGrpcClient",
|
||||
},
|
||||
],
|
||||
},
|
||||
// Should disallow property access for disallowed APIs
|
||||
{
|
||||
code: `const folders = vscode.workspace.workspaceFolders;`,
|
||||
filename: "workspace.ts",
|
||||
errors: [
|
||||
{
|
||||
messageId: "useHostBridge",
|
||||
},
|
||||
],
|
||||
},
|
||||
// Should disallow method calls for disallowed APIs
|
||||
{
|
||||
code: `const relativePath = vscode.workspace.asRelativePath(filePath);`,
|
||||
filename: "path-utils.ts",
|
||||
errors: [
|
||||
{
|
||||
messageId: "usePathUtils",
|
||||
},
|
||||
],
|
||||
},
|
||||
// Should disallow nested property access
|
||||
{
|
||||
code: `const stats = await vscode.workspace.fs.stat(uri);`,
|
||||
filename: "file-utils.ts",
|
||||
errors: [
|
||||
{
|
||||
messageId: "useFsUtils",
|
||||
},
|
||||
],
|
||||
},
|
||||
// Should disallow getting a workspace folder
|
||||
{
|
||||
code: `const folder = vscode.workspace.getWorkspaceFolder(uri);`,
|
||||
filename: "path-helper.ts",
|
||||
errors: [
|
||||
{
|
||||
messageId: "usePathUtils",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -1,11 +1,13 @@
|
||||
// eslint-rules/index.js
|
||||
const noProtobufObjectLiterals = require("./no-protobuf-object-literals")
|
||||
const noGrpcClientObjectLiterals = require("./no-grpc-client-object-literals")
|
||||
const noDirectVscodeApi = require("./no-direct-vscode-api")
|
||||
|
||||
module.exports = {
|
||||
rules: {
|
||||
"no-protobuf-object-literals": noProtobufObjectLiterals,
|
||||
"no-grpc-client-object-literals": noGrpcClientObjectLiterals,
|
||||
"no-direct-vscode-api": noDirectVscodeApi,
|
||||
},
|
||||
configs: {
|
||||
recommended: {
|
||||
@@ -13,6 +15,7 @@ module.exports = {
|
||||
rules: {
|
||||
"local/no-protobuf-object-literals": "error",
|
||||
"local/no-grpc-client-object-literals": "error",
|
||||
"local/no-direct-vscode-api": "warn",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
const { ESLintUtils } = require("@typescript-eslint/utils")
|
||||
const path = require("path")
|
||||
|
||||
const createRule = ESLintUtils.RuleCreator((name) => `https://cline.bot/eslint-rules/${name}`)
|
||||
|
||||
// Configuration of disallowed VSCode APIs and their recommended alternatives
|
||||
const disallowedApis = {
|
||||
"vscode.postMessage": {
|
||||
messageId: "useGrpcClient",
|
||||
},
|
||||
"vscode.workspace.fs.stat": {
|
||||
messageId: "useFsUtils",
|
||||
},
|
||||
"vscode.workspace.workspaceFolders": {
|
||||
messageId: "useHostBridge",
|
||||
},
|
||||
"vscode.workspace.asRelativePath": {
|
||||
messageId: "usePathUtils",
|
||||
},
|
||||
"vscode.workspace.getWorkspaceFolder": {
|
||||
messageId: "usePathUtils",
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = createRule({
|
||||
name: "no-direct-vscode-api",
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow direct VSCode API usage in favor of Cline's abstraction layers, except in src/hosts/vscode and standalone/runtime-files directories",
|
||||
recommended: "error",
|
||||
},
|
||||
messages: {
|
||||
useGrpcClient:
|
||||
"Use gRPC service clients instead of vscode.postMessage().\n" +
|
||||
"Example: AccountServiceClient.methodName(RequestType.create({...})) instead of vscode.postMessage({type: '...'}).\n" +
|
||||
"Found: {{code}}",
|
||||
useFsUtils:
|
||||
"Use utilities in @/utils/fs instead of vscode.workspace.fs.stat.\n" +
|
||||
"Example: import { isDirectory } from '@/utils/fs' or use the file system methods from the host bridge provider.\n" +
|
||||
"Found: {{code}}",
|
||||
useHostBridge:
|
||||
"Use getHostBridgeProvider().workspaceClient.getWorkspacePaths({}) instead of vscode.workspace.workspaceFolders.\n" +
|
||||
"This provides a consistent abstraction across VSCode and standalone environments.\n" +
|
||||
"Found: {{code}}",
|
||||
usePathUtils:
|
||||
"Use path utilities from @/utils/path instead of direct VSCode workspace path methods.\n" +
|
||||
"This provides consistent path handling across different environments.\n" +
|
||||
"Found: {{code}}",
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
defaultOptions: [],
|
||||
|
||||
create(context) {
|
||||
// Check if current file is in an exception directory or is grpc-client-base.ts
|
||||
const filename = context.filename
|
||||
const isGrpcClientBase = path.basename(filename) === "grpc-client-base.ts"
|
||||
|
||||
// Skip checking files in src/hosts/vscode or standalone/runtime-files
|
||||
const isExceptionDirectory = filename.includes("/src/hosts/vscode/") || filename.includes("/standalone/runtime-files/")
|
||||
|
||||
// Pattern for checking memberExpressions like vscode.workspace.fs.stat
|
||||
function checkMemberExpression(node) {
|
||||
// Skip if this file is in an exception directory or is grpc-client-base.ts
|
||||
if (isGrpcClientBase || isExceptionDirectory) {
|
||||
return
|
||||
}
|
||||
|
||||
// For handling nested properties like vscode.workspace.fs.stat
|
||||
function getFullPropertyPath(node) {
|
||||
if (node.type !== "MemberExpression") {
|
||||
return node.name || ""
|
||||
}
|
||||
|
||||
const objectPart = getFullPropertyPath(node.object)
|
||||
const propertyPart = node.property.name || ""
|
||||
|
||||
return objectPart ? `${objectPart}.${propertyPart}` : propertyPart
|
||||
}
|
||||
|
||||
// Check if the expression matches one of our disallowed patterns
|
||||
if (node.object && node.object.type === "Identifier" && node.object.name === "vscode") {
|
||||
const fullPath = `vscode.${node.property.name}`
|
||||
checkDisallowedApi(fullPath, node)
|
||||
}
|
||||
// Handle nested expressions like vscode.workspace.fs.stat
|
||||
else if (node.object && node.object.type === "MemberExpression") {
|
||||
const fullPath = getFullPropertyPath(node)
|
||||
|
||||
// Only proceed if it starts with vscode
|
||||
if (fullPath.startsWith("vscode.")) {
|
||||
checkDisallowedApi(fullPath, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if an expression matches a disallowed API and report if it does
|
||||
function checkDisallowedApi(expressionPath, node) {
|
||||
// Check exact matches
|
||||
if (disallowedApis[expressionPath]) {
|
||||
reportViolation(expressionPath, node)
|
||||
return
|
||||
}
|
||||
|
||||
// Check prefix matches (for nested properties)
|
||||
for (const disallowedApi in disallowedApis) {
|
||||
// For direct property access like vscode.workspace.workspaceFolders
|
||||
if (expressionPath === disallowedApi) {
|
||||
reportViolation(disallowedApi, node)
|
||||
return
|
||||
}
|
||||
|
||||
// For method calls like vscode.workspace.asRelativePath(...)
|
||||
if (expressionPath.startsWith(`${disallowedApi}.`) || expressionPath.startsWith(`${disallowedApi}(`)) {
|
||||
reportViolation(disallowedApi, node)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Report a violation with the appropriate message
|
||||
function reportViolation(disallowedApi, node) {
|
||||
const sourceCode = context.sourceCode
|
||||
const config = disallowedApis[disallowedApi]
|
||||
|
||||
// For method calls, get the whole call expression
|
||||
let reportNode = node
|
||||
let parentNode = sourceCode.getAncestors(node).pop()
|
||||
if (parentNode && parentNode.type === "CallExpression" && parentNode.callee === node) {
|
||||
reportNode = parentNode
|
||||
}
|
||||
|
||||
const callText = sourceCode.getText(reportNode).trim()
|
||||
|
||||
context.report({
|
||||
node: reportNode,
|
||||
messageId: config.messageId,
|
||||
data: {
|
||||
code: callText,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
// Detect basic member expressions (e.g., vscode.postMessage)
|
||||
MemberExpression(node) {
|
||||
checkMemberExpression(node)
|
||||
},
|
||||
|
||||
// Detect property access through destructuring
|
||||
VariableDeclarator(node) {
|
||||
// Skip if this file is in an exception directory or is grpc-client-base.ts
|
||||
if (isGrpcClientBase || isExceptionDirectory) {
|
||||
return
|
||||
}
|
||||
|
||||
// Destructuring pattern checks removed as developers don't use the API this way
|
||||
// They always use direct imports: import * as vscode from "vscode" and direct access: vscode.thing.foo
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
+3
-1
@@ -19,4 +19,6 @@ diff_editing/test_outputs/
|
||||
.cache
|
||||
|
||||
# Python bytecode cache
|
||||
*__pycache__/
|
||||
*__pycache__/
|
||||
|
||||
diff-edits/cases.zip
|
||||
@@ -6,6 +6,7 @@ interface RunDiffEvalOptions {
|
||||
modelIds: string
|
||||
systemPromptName: string
|
||||
validAttemptsPerCase: number
|
||||
maxAttemptsPerCase?: number
|
||||
parsingFunction: string
|
||||
diffEditFunction: string
|
||||
thinkingBudget: number
|
||||
@@ -14,6 +15,9 @@ interface RunDiffEvalOptions {
|
||||
testPath: string
|
||||
outputPath: string
|
||||
replay: boolean
|
||||
replayRunId?: string
|
||||
diffApplyFile?: string
|
||||
saveLocally: boolean
|
||||
maxCases?: number
|
||||
}
|
||||
|
||||
@@ -56,14 +60,30 @@ export async function runDiffEvalHandler(options: RunDiffEvalOptions) {
|
||||
args.push("--replay")
|
||||
}
|
||||
|
||||
if (options.replayRunId) {
|
||||
args.push("--replay-run-id", options.replayRunId)
|
||||
}
|
||||
|
||||
if (options.diffApplyFile) {
|
||||
args.push("--diff-apply-file", options.diffApplyFile)
|
||||
}
|
||||
|
||||
if (options.verbose) {
|
||||
args.push("--verbose")
|
||||
}
|
||||
|
||||
if (options.maxAttemptsPerCase) {
|
||||
args.push("--max-attempts-per-case", String(options.maxAttemptsPerCase))
|
||||
}
|
||||
|
||||
if (options.maxCases) {
|
||||
args.push("--max-cases", String(options.maxCases))
|
||||
}
|
||||
|
||||
if (options.saveLocally) {
|
||||
args.push("--save-locally")
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(chalk.gray(`Executing: npx tsx ${scriptPath} ${args.join(" ")}`))
|
||||
|
||||
|
||||
@@ -87,18 +87,23 @@ program
|
||||
.option("--model-ids <model_ids>", "Comma-separated list of model IDs to test")
|
||||
.option("--system-prompt-name <name>", "The name of the system prompt to use", "basicSystemPrompt")
|
||||
.option("-n, --valid-attempts-per-case <number>", "Number of valid attempts per test case per model (will retry until this many valid attempts are collected)", "1")
|
||||
.option("--max-attempts-per-case <number>", "Maximum total attempts per test case (default: 10x valid attempts)")
|
||||
.option("--max-cases <number>", "Maximum number of test cases to run (limits total cases loaded)")
|
||||
.option("--parsing-function <name>", "The parsing function to use", "parseAssistantMessageV2")
|
||||
.option("--diff-edit-function <name>", "The diff editing function to use", "constructNewFileContentV2")
|
||||
.option("--thinking-budget <tokens>", "Set the thinking tokens budget", "0")
|
||||
.option("--parallel", "Run tests in parallel", false)
|
||||
.option("--replay", "Run evaluation from a pre-recorded LLM output, skipping the API call", false)
|
||||
.option("--replay-run-id <run_id>", "The ID of the run to replay from the database")
|
||||
.option("--diff-apply-file <filename>", "The name of the diff apply file to use for the replay")
|
||||
.option("--save-locally", "Save results to local JSON files in addition to database", false)
|
||||
.option("-v, --verbose", "Enable verbose logging", false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const fullOptions = {
|
||||
...options,
|
||||
validAttemptsPerCase: parseInt(options.validAttemptsPerCase, 10),
|
||||
maxAttemptsPerCase: options.maxAttemptsPerCase ? parseInt(options.maxAttemptsPerCase, 10) : undefined,
|
||||
thinkingBudget: parseInt(options.thinkingBudget, 10),
|
||||
maxCases: options.maxCases ? parseInt(options.maxCases, 10) : undefined,
|
||||
}
|
||||
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
parseAssistantMessageV3,
|
||||
AssistantMessageContent,
|
||||
} from "./parsing/parse-assistant-message-06-06-25" // "../../src/core/assistant-message"
|
||||
import { constructNewFileContent as constructNewFileContentV1, constructNewFileContentV2 } from "./diff-apply/diff-06-06-25"
|
||||
import { constructNewFileContent as constructNewFileContentV3 } from "../../src/core/assistant-message/diff" // this defaults to the new v1 when called
|
||||
import { constructNewFileContent as constructNewFileContent_06_06_25 } from "./diff-apply/diff-06-06-25"
|
||||
import { constructNewFileContent as constructNewFileContent_06_23_25 } from "./diff-apply/diff-06-23-25"
|
||||
import { constructNewFileContent as constructNewFileContent_06_25_25 } from "./diff-apply/diff-06-25-25"
|
||||
import { constructNewFileContent as constructNewFileContent_06_26_25 } from "./diff-apply/diff-06-26-25"
|
||||
|
||||
type ParseAssistantMessageFn = (message: string) => AssistantMessageContent[]
|
||||
type ConstructNewFileContentFn = (diff: string, original: string, strict: boolean) => Promise<string>
|
||||
type ConstructNewFileContentFn = (diff: string, original: string, strict: boolean) => Promise<string | any>
|
||||
|
||||
const parsingFunctions: Record<string, ParseAssistantMessageFn> = {
|
||||
parseAssistantMessageV1: parseAssistantMessageV1,
|
||||
@@ -21,12 +23,14 @@ const parsingFunctions: Record<string, ParseAssistantMessageFn> = {
|
||||
}
|
||||
|
||||
const diffEditingFunctions: Record<string, ConstructNewFileContentFn> = {
|
||||
constructNewFileContentV1: constructNewFileContentV1,
|
||||
constructNewFileContentV2: constructNewFileContentV2,
|
||||
constructNewFileContentV3: constructNewFileContentV3, // position invariant diff
|
||||
"diff-06-06-25": constructNewFileContent_06_06_25,
|
||||
"diff-06-23-25": constructNewFileContent_06_23_25,
|
||||
"diff-06-25-25": constructNewFileContent_06_25_25,
|
||||
"diff-06-26-25": constructNewFileContent_06_26_25,
|
||||
}
|
||||
|
||||
import { TestInput, TestResult, ExtractedToolCall } from "./types"
|
||||
import { log } from "./helpers"
|
||||
export { TestInput, TestResult, ExtractedToolCall }
|
||||
|
||||
interface StreamResult {
|
||||
@@ -151,6 +155,7 @@ export async function runSingleEvaluation(input: TestInput): Promise<TestResult>
|
||||
diffEditFunction,
|
||||
thinkingBudgetTokens,
|
||||
originalDiffEditToolCallMessage,
|
||||
diffApplyFile,
|
||||
} = input
|
||||
|
||||
const requiredParams = {
|
||||
@@ -176,7 +181,7 @@ export async function runSingleEvaluation(input: TestInput): Promise<TestResult>
|
||||
}
|
||||
|
||||
const parseAssistantMessage = parsingFunctions[parsingFunction]
|
||||
const constructNewFileContent = diffEditingFunctions[diffEditFunction]
|
||||
const constructNewFileContent = diffEditingFunctions[diffApplyFile || diffEditFunction]
|
||||
|
||||
if (!parseAssistantMessage || !constructNewFileContent) {
|
||||
return {
|
||||
@@ -280,21 +285,21 @@ export async function runSingleEvaluation(input: TestInput): Promise<TestResult>
|
||||
}
|
||||
|
||||
// check that we are editing the correct file path
|
||||
console.log(`Expected file path: "${originalFilePath}"`);
|
||||
console.log(`Actual file path used: "${diffToolPath}"`);
|
||||
log(input.isVerbose, `Expected file path: "${originalFilePath}"`)
|
||||
log(input.isVerbose, `Actual file path used: "${diffToolPath}"`)
|
||||
if (diffToolPath !== originalFilePath) {
|
||||
console.log(`❌ File path mismatch detected!`);
|
||||
log(input.isVerbose, `❌ File path mismatch detected!`)
|
||||
// Enhanced logging:
|
||||
if (streamResult?.assistantMessage) {
|
||||
console.log(` Full model output (assistantMessage):`);
|
||||
console.log(` -----------------------------------------`);
|
||||
console.log(` ${streamResult.assistantMessage}`);
|
||||
console.log(` -----------------------------------------`);
|
||||
log(input.isVerbose, ` Full model output (assistantMessage):`)
|
||||
log(input.isVerbose, ` -----------------------------------------`)
|
||||
log(input.isVerbose, ` ${streamResult.assistantMessage}`)
|
||||
log(input.isVerbose, ` -----------------------------------------`)
|
||||
}
|
||||
if (toolCall) {
|
||||
console.log(` Parsed tool call that caused mismatch:`);
|
||||
console.log(` ${JSON.stringify(toolCall, null, 2)}`);
|
||||
console.log(` -----------------------------------------`);
|
||||
log(input.isVerbose, ` Parsed tool call that caused mismatch:`)
|
||||
log(input.isVerbose, ` ${JSON.stringify(toolCall, null, 2)}`)
|
||||
log(input.isVerbose, ` -----------------------------------------`)
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
@@ -306,10 +311,18 @@ export async function runSingleEvaluation(input: TestInput): Promise<TestResult>
|
||||
|
||||
// checking if the diff edit succeeds, if it failed it will throw an error
|
||||
let diffSuccess = true
|
||||
let replacementData: any = undefined
|
||||
try {
|
||||
await constructNewFileContent(diffToolContent, originalFile, true)
|
||||
const result = await constructNewFileContent(diffToolContent, originalFile, true)
|
||||
|
||||
// Check if result is an object with replacements (new format)
|
||||
if (typeof result === 'object' && result !== null && 'replacements' in result) {
|
||||
replacementData = result.replacements
|
||||
}
|
||||
// If it's just a string, diffSuccess stays true and replacementData stays undefined
|
||||
} catch (error: any) {
|
||||
diffSuccess = false
|
||||
log(input.isVerbose, `ERROR: ${error}`)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -318,6 +331,7 @@ export async function runSingleEvaluation(input: TestInput): Promise<TestResult>
|
||||
toolCalls: detectedToolCalls,
|
||||
diffEdit: diffToolContent,
|
||||
diffEditSuccess: diffSuccess,
|
||||
replacementData: replacementData,
|
||||
}
|
||||
} catch (error: any) {
|
||||
return {
|
||||
|
||||
@@ -63,9 +63,22 @@ For example, if we ask for 5 valid attempts per test case, the system will keep
|
||||
|
||||
This way, if we're comparing two models and one gets a 10% success rate on its valid diff edit attempts, and another gets 90%, we have a much clearer picture of their actual diff-generating capabilities. It avoids muddying the waters with attempts where the model didn't even try to perform the specific action we're evaluating. This approach helps us isolate and measure the diff-editing skill more directly, despite the non-deterministic nature of these models.
|
||||
|
||||
## Some known edge cases
|
||||
## Replays
|
||||
|
||||
I noticed that some of the current conversation jsons in the `./cases` folder are a little big bogus. Here's a running list of these areas:
|
||||
You can also use the replay argument to replay a previous benchmark run. This is super useful for iterating on our diffing algorithms without having to re-run expensive and time-consuming LLM calls.
|
||||
|
||||
- ~~What if the conversation json was using a model with a massive context window, like Google Gemini's 1M context window, and we're now re-rolling that case on a smaller context window model like claude 4 (200k) or grok-3 (128k)? It's def gonna fail, and we shouldn't just keep trying. We should have a smart system for selecting which cases we can use given the arguments passed in. For example, if we pass in claude and grok with `max cases = 2`, we shouldn't just pick the first two jsons in the folder. We should go through, use a tokenizer, and make sure it would fit with some padding like 20k tokens. Use tiktoken. 20k padding will be sufficient even though different models tokenize differently.~~
|
||||
- There are some weird jsons, where something weird happen, where essentially there's a fluke. Maybe the user was using an extremely dumb model that just hallucinated a fake filepath. Now when we try to reroll that case, we never get a valid case. This can easily be handled by making sure that the file is present before selecting that eval for testing. By "file is present" I mean, that file_contents is present in the eval. Additionally, we should in the streamlit dashboard show cases where getting a valid attempt is a challenge, so we can review those cases more easily and throw them out if they're bogus. Maybe a special tab/page in the dashboard for this purpose. Across all runs / cases, what the most consistently problematic cases are. Pop one open to see the case formatted json with all the user/assistant turns.
|
||||
When you run an evaluation, every detail is stored in the database—including the raw, unmodified output from the model. The replay feature takes advantage of this by pulling that raw output and feeding it into a *different* diffing algorithm. This lets you isolate the performance of the diffing logic itself. We can see if a new algorithm is better at applying the exact same set of diffs that a model generated in a previous run.
|
||||
|
||||
This process is blazingly fast and free, as it completely bypasses the need to make new API calls. It ensures a true apples-to-apples comparison between diffing strategies, since the model's output—the "ground truth" for the evaluation—remains identical.
|
||||
|
||||
Here’s an example of how you would replay a previous run with a new diffing algorithm:
|
||||
|
||||
```shell
|
||||
cd evals && npm run diff-eval -- --replay-run-id 9902189e-63a8-4210-a4fc-fe59e2eaf2c2 --diff-apply-file diff-06-23-25 --verbose
|
||||
```
|
||||
|
||||
In this command:
|
||||
- `--replay-run-id` specifies the original run we want to use as our ground truth.
|
||||
- `--diff-apply-file` tells the script to use the new diffing logic from the `diff-06-23-25.ts` file.
|
||||
|
||||
The script will then create a new run in the database that mirrors the original, but with the results of applying the new diffing algorithm. This allows for a direct comparison in the dashboard, helping us quickly see which of our diffing strategies is the most robust.
|
||||
|
||||
+255
-11
@@ -1,7 +1,13 @@
|
||||
import { runSingleEvaluation, TestInput, TestResult } from "./ClineWrapper"
|
||||
import { parseAssistantMessageV2, AssistantMessageContent } from "./parsing/parse-assistant-message-06-06-25"
|
||||
import { constructNewFileContent as constructNewFileContent_06_06_25 } from "./diff-apply/diff-06-06-25"
|
||||
import { constructNewFileContent as constructNewFileContent_06_23_25 } from "./diff-apply/diff-06-23-25"
|
||||
import { constructNewFileContent as constructNewFileContent_06_25_25 } from "./diff-apply/diff-06-25-25"
|
||||
import { constructNewFileContent as constructNewFileContent_06_26_25 } from "./diff-apply/diff-06-26-25"
|
||||
import { constructNewFileContent as constructNewFileContentV3 } from "../../src/core/assistant-message/diff"
|
||||
import { basicSystemPrompt } from "./prompts/basicSystemPrompt-06-06-25"
|
||||
import { claude4SystemPrompt } from "./prompts/claude4SystemPrompt-06-06-25"
|
||||
import { formatResponse } from "./helpers"
|
||||
import { formatResponse, log } from "./helpers"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
@@ -18,6 +24,10 @@ import {
|
||||
insertResult,
|
||||
DatabaseClient,
|
||||
CreateResultInput,
|
||||
getResultsByRun,
|
||||
getCaseById,
|
||||
getFileByHash,
|
||||
getBenchmarkRun,
|
||||
} from "./database"
|
||||
|
||||
// Load environment variables from .env file
|
||||
@@ -30,12 +40,6 @@ const encoding = get_encoding("cl100k_base");
|
||||
|
||||
let openRouterModelDataGlobal: Record<string, EvalOpenRouterModelInfo> = {}; // Global to store fetched data
|
||||
|
||||
function log(isVerbose: boolean, message: string) {
|
||||
if (isVerbose) {
|
||||
console.log(message)
|
||||
}
|
||||
}
|
||||
|
||||
const systemPromptGeneratorLookup: Record<string, ConstructSystemPromptFn> = {
|
||||
basicSystemPrompt: basicSystemPrompt,
|
||||
claude4SystemPrompt: claude4SystemPrompt,
|
||||
@@ -184,6 +188,78 @@ class NodeTestRunner {
|
||||
log(isVerbose, `✓ Created ${this.caseIdMap.size} database case records`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store replay result in database, copying original data but with new diffing results
|
||||
*/
|
||||
async storeReplayResultInDatabase(replayResult: TestResult, originalResult: any, testId: string, newCaseId: string): Promise<void> {
|
||||
if (!this.currentRunId || !this.processingFunctionsHash) {
|
||||
return; // Skip if database not initialized
|
||||
}
|
||||
|
||||
try {
|
||||
// Map error string to error enum (simple mapping)
|
||||
const errorEnum = this.mapErrorToEnum(replayResult.error);
|
||||
|
||||
// Store diff edit content if available
|
||||
let fileEditedHash: string | undefined;
|
||||
if (replayResult.diffEdit) {
|
||||
fileEditedHash = await upsertFile({
|
||||
filepath: `diff-edit-${testId}`,
|
||||
content: replayResult.diffEdit
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate basic metrics from diff edit if available
|
||||
let numEdits = 0;
|
||||
let numLinesAdded = 0;
|
||||
let numLinesDeleted = 0;
|
||||
|
||||
if (replayResult.diffEdit) {
|
||||
// Simple parsing to count edits - count SEARCH/REPLACE blocks
|
||||
const searchBlocks = (replayResult.diffEdit.match(/------- SEARCH/g) || []).length;
|
||||
numEdits = searchBlocks;
|
||||
|
||||
// Count added/deleted lines (rough approximation)
|
||||
const lines = replayResult.diffEdit.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('+') && !line.startsWith('+++')) {
|
||||
numLinesAdded++;
|
||||
} else if (line.startsWith('-') && !line.startsWith('---')) {
|
||||
numLinesDeleted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy original result data but update replay-specific fields
|
||||
const resultInput: CreateResultInput = {
|
||||
run_id: this.currentRunId, // New run ID
|
||||
case_id: newCaseId, // New case ID
|
||||
model_id: originalResult.model_id, // Copy from original
|
||||
processing_functions_hash: this.processingFunctionsHash, // New processing functions
|
||||
succeeded: replayResult.success && (replayResult.diffEditSuccess ?? false), // New result
|
||||
error_enum: errorEnum, // New error if any
|
||||
num_edits: numEdits || originalResult.num_edits, // New or original
|
||||
num_lines_deleted: numLinesDeleted || originalResult.num_lines_deleted, // New or original
|
||||
num_lines_added: numLinesAdded || originalResult.num_lines_added, // New or original
|
||||
// Copy timing and cost data from original (since we didn't make API calls)
|
||||
time_to_first_token_ms: originalResult.time_to_first_token_ms,
|
||||
time_to_first_edit_ms: originalResult.time_to_first_edit_ms,
|
||||
time_round_trip_ms: originalResult.time_round_trip_ms,
|
||||
cost_usd: originalResult.cost_usd,
|
||||
completion_tokens: originalResult.completion_tokens,
|
||||
// Use original model output (since we're replaying)
|
||||
raw_model_output: originalResult.raw_model_output,
|
||||
file_edited_hash: fileEditedHash || originalResult.file_edited_hash,
|
||||
parsed_tool_call_json: replayResult.toolCalls ? JSON.stringify(replayResult.toolCalls) : originalResult.parsed_tool_call_json
|
||||
};
|
||||
|
||||
await insertResult(resultInput);
|
||||
} catch (error) {
|
||||
console.error(`Failed to store replay result in database for ${testId}:`, error);
|
||||
// Continue execution - don't fail the test run
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store test result in database
|
||||
*/
|
||||
@@ -394,6 +470,144 @@ class NodeTestRunner {
|
||||
}
|
||||
}
|
||||
|
||||
async runDatabaseReplay(replayRunId: string, diffApplyFile: string, isVerbose: boolean) {
|
||||
log(isVerbose, `Starting database replay for run_id: ${replayRunId}`)
|
||||
log(isVerbose, `Using diff apply file: ${diffApplyFile}`)
|
||||
|
||||
// 1. Get the correct diffing function
|
||||
const diffEditingFunctions: Record<string, any> = {
|
||||
"diff-06-06-25": constructNewFileContent_06_06_25,
|
||||
"diff-06-23-25": constructNewFileContent_06_23_25,
|
||||
"diff-06-25-25": constructNewFileContent_06_25_25,
|
||||
"diff-06-26-25": constructNewFileContent_06_26_25,
|
||||
constructNewFileContentV3: constructNewFileContentV3,
|
||||
}
|
||||
const constructNewFileContent = diffEditingFunctions[diffApplyFile]
|
||||
|
||||
if (!constructNewFileContent) {
|
||||
throw new Error(`Could not find diff apply function for: ${diffApplyFile}`)
|
||||
}
|
||||
log(isVerbose, `Successfully loaded diff apply function: ${diffApplyFile}`)
|
||||
|
||||
// 2. Fetch original run data
|
||||
const originalResults = await getResultsByRun(replayRunId)
|
||||
if (originalResults.length === 0) {
|
||||
throw new Error(`No results found for run_id: ${replayRunId}`)
|
||||
}
|
||||
log(isVerbose, `Found ${originalResults.length} results to replay.`)
|
||||
|
||||
const originalRun = await getBenchmarkRun(replayRunId)
|
||||
if (!originalRun) {
|
||||
throw new Error(`Could not find original run with id ${replayRunId}`)
|
||||
}
|
||||
|
||||
// 3. Create a new benchmark run for the replay
|
||||
const replayRunDescription = `Replay of run ${replayRunId} using ${diffApplyFile}`
|
||||
this.currentRunId = await createBenchmarkRun({
|
||||
description: replayRunDescription,
|
||||
system_prompt_hash: originalRun.system_prompt_hash,
|
||||
})
|
||||
log(isVerbose, `Created new run for replay: ${this.currentRunId}`)
|
||||
|
||||
// 4. Set up processing functions for the new run
|
||||
this.processingFunctionsHash = await upsertProcessingFunctions({
|
||||
name: `replay-${diffApplyFile}`,
|
||||
parsing_function: "parseAssistantMessageV2",
|
||||
diff_edit_function: diffApplyFile,
|
||||
})
|
||||
|
||||
// 5. Process each result from the original run
|
||||
let replayedCount = 0
|
||||
const caseIdMirror: Map<string, string> = new Map()
|
||||
|
||||
for (const originalResult of originalResults) {
|
||||
// 5a. Basic validation to ensure we can even process this
|
||||
if (!originalResult.case_id) {
|
||||
log(isVerbose, `Skipping result ${originalResult.result_id} due to missing case_id.`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 5b. Mirror the case for the new run, reusing if already created
|
||||
let newCaseId = caseIdMirror.get(originalResult.case_id)
|
||||
if (!newCaseId) {
|
||||
const originalCase = await getCaseById(originalResult.case_id)
|
||||
if (!originalCase) {
|
||||
log(isVerbose, `Skipping result ${originalResult.result_id} because original case could not be found.`)
|
||||
continue
|
||||
}
|
||||
newCaseId = await createCase({
|
||||
run_id: this.currentRunId,
|
||||
description: `Replay of case ${originalCase.case_id} from run ${replayRunId}`,
|
||||
system_prompt_hash: originalCase.system_prompt_hash,
|
||||
task_id: originalCase.task_id,
|
||||
tokens_in_context: originalCase.tokens_in_context,
|
||||
file_hash: originalCase.file_hash,
|
||||
})
|
||||
caseIdMirror.set(originalResult.case_id, newCaseId)
|
||||
}
|
||||
|
||||
// 5c. Determine if the original attempt was a "valid attempt"
|
||||
const isValidOriginalAttempt = originalResult.error_enum === null || originalResult.error_enum === 3 // 3 is diff_edit_error
|
||||
|
||||
const newResultInput: CreateResultInput = {
|
||||
...(originalResult as any),
|
||||
run_id: this.currentRunId,
|
||||
case_id: newCaseId,
|
||||
processing_functions_hash: this.processingFunctionsHash,
|
||||
}
|
||||
delete (newResultInput as any).result_id
|
||||
|
||||
if (isValidOriginalAttempt) {
|
||||
// This was a valid attempt. Re-run the diff algorithm.
|
||||
const originalCase = await getCaseById(originalResult.case_id)
|
||||
if (!originalCase) {
|
||||
log(isVerbose, ` [WARN] Replay for result ${originalResult.result_id}: Could not find original case. Copying original result.`)
|
||||
newResultInput.succeeded = originalResult.succeeded
|
||||
newResultInput.error_enum = originalResult.error_enum
|
||||
} else {
|
||||
const originalFile = originalCase.file_hash ? await getFileByHash(originalCase.file_hash) : null
|
||||
const parsedToolCall = originalResult.parsed_tool_call_json ? JSON.parse(originalResult.parsed_tool_call_json)[0] : null
|
||||
const diffContent = parsedToolCall?.input?.diff
|
||||
|
||||
if (originalFile && diffContent) {
|
||||
let diffSuccess = false
|
||||
try {
|
||||
await constructNewFileContent(diffContent, originalFile.content, true)
|
||||
diffSuccess = true
|
||||
log(isVerbose, ` [OK] Replay for task ${originalCase.task_id}: Diff applied successfully.`)
|
||||
} catch (e) {
|
||||
diffSuccess = false
|
||||
log(isVerbose, ` [FAIL] Replay for task ${originalCase.task_id}: New diff algorithm failed.`)
|
||||
}
|
||||
newResultInput.succeeded = diffSuccess
|
||||
newResultInput.error_enum = diffSuccess ? undefined : 3 // 3 = diff_edit_error
|
||||
} else {
|
||||
// Something is wrong with the ground truth data, just copy it.
|
||||
log(
|
||||
isVerbose,
|
||||
` [WARN] Replay for task ${originalCase.task_id}: Valid original attempt but missing file or diff content. Copying original result.`,
|
||||
)
|
||||
newResultInput.succeeded = originalResult.succeeded
|
||||
newResultInput.error_enum = originalResult.error_enum
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// This was not a valid attempt. Just copy the original result's outcome.
|
||||
log(isVerbose, ` [SKIP] Replay for task ${originalResult.case_id}: Invalid original attempt. Copying original result.`)
|
||||
newResultInput.succeeded = originalResult.succeeded
|
||||
newResultInput.error_enum = originalResult.error_enum
|
||||
}
|
||||
|
||||
await insertResult(newResultInput)
|
||||
replayedCount++
|
||||
}
|
||||
|
||||
log(isVerbose, `\n✓ Database replay completed successfully.`)
|
||||
log(isVerbose, ` Total original results: ${originalResults.length}`)
|
||||
log(isVerbose, ` Total replayed results: ${replayedCount}`)
|
||||
log(isVerbose, ` New run ID: ${this.currentRunId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single test example
|
||||
*/
|
||||
@@ -420,6 +634,8 @@ class NodeTestRunner {
|
||||
diffEditFunction: testConfig.diff_edit_function,
|
||||
thinkingBudgetTokens: testConfig.thinking_tokens_budget,
|
||||
originalDiffEditToolCallMessage: testConfig.replay ? testCase.original_diff_edit_tool_call_message : undefined,
|
||||
diffApplyFile: testConfig.diff_apply_file,
|
||||
isVerbose: isVerbose,
|
||||
}
|
||||
|
||||
if (isVerbose) {
|
||||
@@ -586,8 +802,8 @@ class NodeTestRunner {
|
||||
log(isVerbose, `Warning: Failed to store result in database: ${error}`);
|
||||
}
|
||||
|
||||
// Safety check to prevent infinite loops - limit to 10 attempts per valid attempt requested
|
||||
if (totalAttempts >= testConfig.number_of_runs * 10) {
|
||||
// Safety check to prevent infinite loops - use configurable max attempts limit
|
||||
if (totalAttempts >= testConfig.max_attempts_per_case) {
|
||||
log(isVerbose, ` ⚠️ Reached maximum attempts (${totalAttempts}) for test case ${testCase.test_id}. Only got ${validAttempts}/${testConfig.number_of_runs} valid attempts.`);
|
||||
break;
|
||||
}
|
||||
@@ -706,12 +922,16 @@ async function main() {
|
||||
.option("--model-ids <model_ids>", "Comma-separated list of model IDs to test")
|
||||
.option("--system-prompt-name <name>", "The name of the system prompt to use", "basicSystemPrompt")
|
||||
.option("-n, --valid-attempts-per-case <number>", "Number of valid attempts per test case per model (will retry until this many valid attempts are collected)", "1")
|
||||
.option("--max-attempts-per-case <number>", "Maximum total attempts per test case (default: 10x valid attempts)")
|
||||
.option("--max-cases <number>", "Maximum number of test cases to run (limits total cases loaded)")
|
||||
.option("--parsing-function <name>", "The parsing function to use", "parseAssistantMessageV2")
|
||||
.option("--diff-edit-function <name>", "The diff editing function to use", "constructNewFileContentV2")
|
||||
.option("--diff-edit-function <name>", "The diff editing function to use", "diff-06-25-25")
|
||||
.option("--thinking-budget <tokens>", "Set the thinking tokens budget", "0")
|
||||
.option("--parallel", "Run tests in parallel", false)
|
||||
.option("--replay", "Run evaluation from a pre-recorded LLM output, skipping the API call", false)
|
||||
.option("--replay-run-id <run_id>", "The ID of the run to replay from the database")
|
||||
.option("--diff-apply-file <filename>", "The name of the diff apply file to use for the replay")
|
||||
.option("--save-locally", "Save results to local JSON files in addition to database", false)
|
||||
.option("-v, --verbose", "Enable verbose logging", false)
|
||||
.option("--max-concurrency <number>", "Maximum number of parallel requests", "80")
|
||||
|
||||
@@ -722,6 +942,7 @@ async function main() {
|
||||
const isVerbose = options.verbose
|
||||
const testPath = options.testPath
|
||||
const outputPath = options.outputPath
|
||||
const saveLocally = options.saveLocally
|
||||
const maxConcurrency = parseInt(options.maxConcurrency, 10);
|
||||
|
||||
// Parse model IDs from comma-separated string
|
||||
@@ -733,6 +954,22 @@ async function main() {
|
||||
|
||||
const validAttemptsPerCase = parseInt(options.validAttemptsPerCase, 10);
|
||||
|
||||
// Compute dynamic default for max attempts: 10x valid attempts if not specified
|
||||
const maxAttemptsPerCase = options.maxAttemptsPerCase
|
||||
? parseInt(options.maxAttemptsPerCase, 10)
|
||||
: validAttemptsPerCase * 10;
|
||||
|
||||
const runner = new NodeTestRunner(options.replay || !!options.replayRunId)
|
||||
|
||||
if (options.replayRunId) {
|
||||
if (!options.diffApplyFile) {
|
||||
console.error("Error: --diff-apply-file is required when using --replay-run-id")
|
||||
process.exit(1)
|
||||
}
|
||||
await runner.runDatabaseReplay(options.replayRunId, options.diffApplyFile, isVerbose)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const startTime = Date.now()
|
||||
|
||||
@@ -826,6 +1063,7 @@ async function main() {
|
||||
model_id: modelId,
|
||||
system_prompt_name: options.systemPromptName,
|
||||
number_of_runs: validAttemptsPerCase,
|
||||
max_attempts_per_case: maxAttemptsPerCase,
|
||||
parsing_function: options.parsingFunction,
|
||||
diff_edit_function: options.diffEditFunction,
|
||||
thinking_tokens_budget: parseInt(options.thinkingBudget, 10),
|
||||
@@ -893,7 +1131,7 @@ async function main() {
|
||||
|
||||
remainingTasks = remainingTasks.filter(task => {
|
||||
const taskId = `${task.modelId}-${task.testCase.test_id}`;
|
||||
if (taskStates[taskId].total >= validAttemptsPerCase * 10) {
|
||||
if (taskStates[taskId].total >= task.testConfig.max_attempts_per_case) {
|
||||
log(isVerbose, ` ⚠️ Reached maximum attempts for ${task.testCase.test_id} with ${task.modelId}.`);
|
||||
return false;
|
||||
}
|
||||
@@ -918,6 +1156,12 @@ async function main() {
|
||||
const durationSeconds = ((endTime - startTime) / 1000).toFixed(2)
|
||||
log(isVerbose, `\n-Total execution time: ${durationSeconds} seconds`)
|
||||
|
||||
// Save results locally if requested
|
||||
if (saveLocally) {
|
||||
runner.saveTestResults(results, outputPath);
|
||||
log(isVerbose, `✓ Results also saved to JSON files in ${outputPath}`);
|
||||
}
|
||||
|
||||
log(isVerbose, `\n✓ All results stored in database. Use the dashboard to view results.`)
|
||||
} catch (error) {
|
||||
console.error("\nError running tests:", error)
|
||||
|
||||
@@ -420,21 +420,33 @@ def render_model_comparison_cards(model_performance):
|
||||
metric_col1, metric_col2, metric_col3, metric_col4 = st.columns(4)
|
||||
|
||||
with metric_col1:
|
||||
st.metric("Avg Latency", f"{model['avg_round_trip_ms']:.0f}ms")
|
||||
if pd.notna(model['avg_round_trip_ms']):
|
||||
st.metric("Avg Latency", f"{model['avg_round_trip_ms']:.0f}ms")
|
||||
else:
|
||||
st.metric("Avg Latency", "N/A")
|
||||
|
||||
with metric_col2:
|
||||
st.metric("Avg Cost", f"${model['avg_cost']:.4f}")
|
||||
if pd.notna(model['avg_cost']):
|
||||
st.metric("Avg Cost", f"${model['avg_cost']:.4f}")
|
||||
else:
|
||||
st.metric("Avg Cost", "N/A")
|
||||
|
||||
with metric_col3:
|
||||
st.metric("Valid Results", f"{model['total_results']}")
|
||||
|
||||
with metric_col4:
|
||||
st.metric("First Token", f"{model['avg_first_token_ms']:.0f}ms")
|
||||
if pd.notna(model['avg_first_token_ms']):
|
||||
st.metric("First Token", f"{model['avg_first_token_ms']:.0f}ms")
|
||||
else:
|
||||
st.metric("First Token", "N/A")
|
||||
|
||||
with col2:
|
||||
st.write("") # Add some spacing
|
||||
if st.button(f"Drill Down", key=f"drill_{model['model_id']}", use_container_width=True):
|
||||
st.session_state.drill_down_model = model['model_id']
|
||||
# Update URL with model_id for drill down
|
||||
st.query_params["model_id"] = model['model_id']
|
||||
st.rerun()
|
||||
|
||||
st.divider() # Add a divider between models
|
||||
|
||||
@@ -835,6 +847,11 @@ def main():
|
||||
if 'selected_run_id' not in st.session_state:
|
||||
st.session_state.selected_run_id = None
|
||||
|
||||
# Handle URL parameters for direct linking
|
||||
query_params = st.query_params
|
||||
url_run_id = query_params.get("run_id")
|
||||
url_model_id = query_params.get("model_id")
|
||||
|
||||
# Load all runs for sidebar
|
||||
all_runs = load_all_runs()
|
||||
|
||||
@@ -842,6 +859,18 @@ def main():
|
||||
st.error("No evaluation runs found in the database.")
|
||||
st.stop()
|
||||
|
||||
# Set initial run selection from URL or default to latest
|
||||
if url_run_id and url_run_id in all_runs['run_id'].values:
|
||||
if st.session_state.selected_run_id != url_run_id:
|
||||
st.session_state.selected_run_id = url_run_id
|
||||
st.session_state.drill_down_model = None # Reset drill down when changing runs via URL
|
||||
elif st.session_state.selected_run_id is None:
|
||||
st.session_state.selected_run_id = all_runs.iloc[0]['run_id'] # Default to latest
|
||||
|
||||
# Set drill down model from URL
|
||||
if url_model_id and st.session_state.selected_run_id == url_run_id:
|
||||
st.session_state.drill_down_model = url_model_id
|
||||
|
||||
# Sidebar for run selection
|
||||
with st.sidebar:
|
||||
st.markdown("## 📊 Evaluation Runs")
|
||||
@@ -887,6 +916,10 @@ def main():
|
||||
if run_ids[selected_run_idx] != st.session_state.selected_run_id:
|
||||
st.session_state.selected_run_id = run_ids[selected_run_idx]
|
||||
st.session_state.drill_down_model = None # Reset drill down when changing runs
|
||||
# Update URL with new run_id
|
||||
st.query_params["run_id"] = st.session_state.selected_run_id
|
||||
if "model_id" in st.query_params:
|
||||
del st.query_params["model_id"] # Clear model_id when changing runs
|
||||
st.rerun()
|
||||
|
||||
# Show run details in sidebar
|
||||
@@ -897,6 +930,57 @@ def main():
|
||||
st.markdown(f"**Created:** {selected_run['created_at']}")
|
||||
if selected_run['description']:
|
||||
st.markdown(f"**Description:** {selected_run['description']}")
|
||||
|
||||
# Show shareable URL
|
||||
st.markdown("---")
|
||||
st.markdown("### 🔗 Share This View")
|
||||
|
||||
# Build current URL
|
||||
# Dynamically derive the base URL
|
||||
server_address = st.server.server_address if hasattr(st.server, 'server_address') else "localhost"
|
||||
server_port = st.server.server_port if hasattr(st.server, 'server_port') else "8501"
|
||||
base_url = f"http://{server_address}:{server_port}"
|
||||
current_url = f"{base_url}/?run_id={st.session_state.selected_run_id}"
|
||||
if st.session_state.drill_down_model:
|
||||
current_url += f"&model_id={st.session_state.drill_down_model}"
|
||||
|
||||
st.markdown("**Current URL:**")
|
||||
st.code(current_url, language=None)
|
||||
|
||||
# Copy button using HTML/JS
|
||||
copy_button_html = f"""
|
||||
<button onclick="copyToClipboard('{current_url}')" style="
|
||||
padding: 8px 16px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #ccc;
|
||||
background: #f0f2f6;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin-top: 5px;
|
||||
">📋 Copy Link</button>
|
||||
<script>
|
||||
function copyToClipboard(text) {{
|
||||
navigator.clipboard.writeText(text).then(function() {{
|
||||
// Success feedback
|
||||
event.target.innerText = '✅ Copied!';
|
||||
event.target.style.backgroundColor = '#d4edda';
|
||||
setTimeout(() => {{
|
||||
event.target.innerText = '📋 Copy Link';
|
||||
event.target.style.backgroundColor = '#f0f2f6';
|
||||
}}, 2000);
|
||||
}}, function(err) {{
|
||||
// Error feedback
|
||||
event.target.innerText = '❌ Failed';
|
||||
event.target.style.backgroundColor = '#f8d7da';
|
||||
setTimeout(() => {{
|
||||
event.target.innerText = '📋 Copy Link';
|
||||
event.target.style.backgroundColor = '#f0f2f6';
|
||||
}}, 2000);
|
||||
}});
|
||||
}}
|
||||
</script>
|
||||
"""
|
||||
st.components.v1.html(copy_button_html, height=50)
|
||||
|
||||
# Load data for selected run
|
||||
current_run, model_performance = load_run_comparison(st.session_state.selected_run_id)
|
||||
@@ -914,6 +998,9 @@ def main():
|
||||
with col1:
|
||||
if st.button("Back to Overview", use_container_width=True):
|
||||
st.session_state.drill_down_model = None
|
||||
# Clear model_id from URL when going back to overview
|
||||
if "model_id" in st.query_params:
|
||||
del st.query_params["model_id"]
|
||||
st.rerun()
|
||||
|
||||
render_detailed_analysis(current_run['run_id'], st.session_state.drill_down_model)
|
||||
|
||||
@@ -33,7 +33,7 @@ This is the most granular and important table in the database. A `result` repres
|
||||
|
||||
- **Purpose**: To store the detailed outcome of every single model attempt, providing the raw data for all quantitative and qualitative analysis.
|
||||
- **Key Columns**:
|
||||
- `result_id`: A unique identifier for the individual attempt.
|
||||
- `result_id`: The primary key for the result.
|
||||
- `run_id`, `case_id`, `model_id`, `processing_functions_hash`: A set of foreign keys that precisely situate this result within the context of a specific run, case, model, and set of helper functions.
|
||||
- `succeeded`: A boolean indicating if the generated diff was applied successfully.
|
||||
- `error_enum`: A numeric code representing the specific type of error if the attempt failed (e.g., `1` for `no_tool_calls`, `7` for `wrong_file_edited`).
|
||||
@@ -82,3 +82,15 @@ This relational schema provides a powerful foundation for sophisticated analysis
|
||||
- "Which of our internal diffing algorithms is the most robust against a wide range of model-generated edits?"
|
||||
|
||||
Ultimately, this data model enables us to move from simply *measuring* performance to truly *understanding* it, providing the insights needed to build more capable and reliable AI engineering systems.
|
||||
|
||||
---
|
||||
|
||||
## Viewing the Full Schema
|
||||
|
||||
To see the most up-to-date and detailed schema for the database, you can use the `sqlite3` command-line tool. From the `evals/diff-edits` directory, run the following command:
|
||||
|
||||
```bash
|
||||
sqlite3 evals.db .schema
|
||||
```
|
||||
|
||||
This will print the complete `CREATE TABLE` statements for all tables in the database, providing a definitive reference for the database structure.
|
||||
|
||||
@@ -0,0 +1,827 @@
|
||||
const SEARCH_BLOCK_START = "------- SEARCH"
|
||||
const SEARCH_BLOCK_END = "======="
|
||||
const REPLACE_BLOCK_END = "+++++++ REPLACE"
|
||||
|
||||
const SEARCH_BLOCK_CHAR = "-"
|
||||
const REPLACE_BLOCK_CHAR = "+"
|
||||
const LEGACY_SEARCH_BLOCK_CHAR = "<"
|
||||
const LEGACY_REPLACE_BLOCK_CHAR = ">"
|
||||
|
||||
// Replace the exact string constants with flexible regex patterns
|
||||
const SEARCH_BLOCK_START_REGEX = /^[-]{3,} SEARCH$/
|
||||
const SEARCH_BLOCK_END_REGEX = /^[=]{3,}$/
|
||||
const REPLACE_BLOCK_END_REGEX = /^[+]{3,} REPLACE$/
|
||||
const LEGACY_SEARCH_BLOCK_START_REGEX = /^[<]{3,} SEARCH$/
|
||||
const LEGACY_REPLACE_BLOCK_END_REGEX = /^[>]{3,} REPLACE$/
|
||||
|
||||
// Helper functions to check if a line matches the flexible patterns
|
||||
function isSearchBlockStart(line: string): boolean {
|
||||
return SEARCH_BLOCK_START_REGEX.test(line) || LEGACY_SEARCH_BLOCK_START_REGEX.test(line)
|
||||
}
|
||||
|
||||
function isSearchBlockEnd(line: string): boolean {
|
||||
return SEARCH_BLOCK_END_REGEX.test(line)
|
||||
}
|
||||
|
||||
function isReplaceBlockEnd(line: string): boolean {
|
||||
return REPLACE_BLOCK_END_REGEX.test(line) || LEGACY_REPLACE_BLOCK_END_REGEX.test(line)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts a line-trimmed fallback match for the given search content in the original content.
|
||||
* It tries to match `searchContent` lines against a block of lines in `originalContent` starting
|
||||
* from `lastProcessedIndex`. Lines are matched by trimming leading/trailing whitespace and ensuring
|
||||
* they are identical afterwards.
|
||||
*
|
||||
* Returns [matchIndexStart, matchIndexEnd] if found, or false if not found.
|
||||
*/
|
||||
function lineTrimmedFallbackMatch(originalContent: string, searchContent: string, startIndex: number): [number, number] | false {
|
||||
// Split both contents into lines
|
||||
const originalLines = originalContent.split("\n")
|
||||
const searchLines = searchContent.split("\n")
|
||||
|
||||
// Trim trailing empty line if exists (from the trailing \n in searchContent)
|
||||
if (searchLines[searchLines.length - 1] === "") {
|
||||
searchLines.pop()
|
||||
}
|
||||
|
||||
// Find the line number where startIndex falls
|
||||
let startLineNum = 0
|
||||
let currentIndex = 0
|
||||
while (currentIndex < startIndex && startLineNum < originalLines.length) {
|
||||
currentIndex += originalLines[startLineNum].length + 1 // +1 for \n
|
||||
startLineNum++
|
||||
}
|
||||
|
||||
// For each possible starting position in original content
|
||||
for (let i = startLineNum; i <= originalLines.length - searchLines.length; i++) {
|
||||
let matches = true
|
||||
|
||||
// Try to match all search lines from this position
|
||||
for (let j = 0; j < searchLines.length; j++) {
|
||||
const originalTrimmed = originalLines[i + j].trim()
|
||||
const searchTrimmed = searchLines[j].trim()
|
||||
|
||||
if (originalTrimmed !== searchTrimmed) {
|
||||
matches = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a match, calculate the exact character positions
|
||||
if (matches) {
|
||||
// Find start character index
|
||||
let matchStartIndex = 0
|
||||
for (let k = 0; k < i; k++) {
|
||||
matchStartIndex += originalLines[k].length + 1 // +1 for \n
|
||||
}
|
||||
|
||||
// Find end character index
|
||||
let matchEndIndex = matchStartIndex
|
||||
for (let k = 0; k < searchLines.length; k++) {
|
||||
matchEndIndex += originalLines[i + k].length + 1 // +1 for \n
|
||||
}
|
||||
|
||||
return [matchStartIndex, matchEndIndex]
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to match blocks of code by using the first and last lines as anchors.
|
||||
* This is a third-tier fallback strategy that helps match blocks where we can identify
|
||||
* the correct location by matching the beginning and end, even if the exact content
|
||||
* differs slightly.
|
||||
*
|
||||
* The matching strategy:
|
||||
* 1. Only attempts to match blocks of 3 or more lines to avoid false positives
|
||||
* 2. Extracts from the search content:
|
||||
* - First line as the "start anchor"
|
||||
* - Last line as the "end anchor"
|
||||
* 3. For each position in the original content:
|
||||
* - Checks if the next line matches the start anchor
|
||||
* - If it does, jumps ahead by the search block size
|
||||
* - Checks if that line matches the end anchor
|
||||
* - All comparisons are done after trimming whitespace
|
||||
*
|
||||
* This approach is particularly useful for matching blocks of code where:
|
||||
* - The exact content might have minor differences
|
||||
* - The beginning and end of the block are distinctive enough to serve as anchors
|
||||
* - The overall structure (number of lines) remains the same
|
||||
*
|
||||
* @param originalContent - The full content of the original file
|
||||
* @param searchContent - The content we're trying to find in the original file
|
||||
* @param startIndex - The character index in originalContent where to start searching
|
||||
* @returns A tuple of [startIndex, endIndex] if a match is found, false otherwise
|
||||
*/
|
||||
function blockAnchorFallbackMatch(originalContent: string, searchContent: string, startIndex: number): [number, number] | false {
|
||||
const originalLines = originalContent.split("\n")
|
||||
const searchLines = searchContent.split("\n")
|
||||
|
||||
// Only use this approach for blocks of 3+ lines
|
||||
if (searchLines.length < 3) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Trim trailing empty line if exists
|
||||
if (searchLines[searchLines.length - 1] === "") {
|
||||
searchLines.pop()
|
||||
}
|
||||
|
||||
const firstLineSearch = searchLines[0].trim()
|
||||
const lastLineSearch = searchLines[searchLines.length - 1].trim()
|
||||
const searchBlockSize = searchLines.length
|
||||
|
||||
// Find the line number where startIndex falls
|
||||
let startLineNum = 0
|
||||
let currentIndex = 0
|
||||
while (currentIndex < startIndex && startLineNum < originalLines.length) {
|
||||
currentIndex += originalLines[startLineNum].length + 1
|
||||
startLineNum++
|
||||
}
|
||||
|
||||
// Look for matching start and end anchors
|
||||
for (let i = startLineNum; i <= originalLines.length - searchBlockSize; i++) {
|
||||
// Check if first line matches
|
||||
if (originalLines[i].trim() !== firstLineSearch) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if last line matches at the expected position
|
||||
if (originalLines[i + searchBlockSize - 1].trim() !== lastLineSearch) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate exact character positions
|
||||
let matchStartIndex = 0
|
||||
for (let k = 0; k < i; k++) {
|
||||
matchStartIndex += originalLines[k].length + 1
|
||||
}
|
||||
|
||||
let matchEndIndex = matchStartIndex
|
||||
for (let k = 0; k < searchBlockSize; k++) {
|
||||
matchEndIndex += originalLines[i + k].length + 1
|
||||
}
|
||||
|
||||
return [matchStartIndex, matchEndIndex]
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* This function reconstructs the file content by applying a streamed diff (in a
|
||||
* specialized SEARCH/REPLACE block format) to the original file content. It is designed
|
||||
* to handle both incremental updates and the final resulting file after all chunks have
|
||||
* been processed.
|
||||
*
|
||||
* The diff format is a custom structure that uses three markers to define changes:
|
||||
*
|
||||
* ------- SEARCH
|
||||
* [Exact content to find in the original file]
|
||||
* =======
|
||||
* [Content to replace with]
|
||||
* +++++++ REPLACE
|
||||
*
|
||||
* Behavior and Assumptions:
|
||||
* 1. The file is processed chunk-by-chunk. Each chunk of `diffContent` may contain
|
||||
* partial or complete SEARCH/REPLACE blocks. By calling this function with each
|
||||
* incremental chunk (with `isFinal` indicating the last chunk), the final reconstructed
|
||||
* file content is produced.
|
||||
*
|
||||
* 2. Matching Strategy (in order of attempt):
|
||||
* a. Exact Match: First attempts to find the exact SEARCH block text in the original file
|
||||
* b. Line-Trimmed Match: Falls back to line-by-line comparison ignoring leading/trailing whitespace
|
||||
* c. Block Anchor Match: For blocks of 3+ lines, tries to match using first/last lines as anchors
|
||||
* If all matching strategies fail, an error is thrown.
|
||||
*
|
||||
* 3. Empty SEARCH Section:
|
||||
* - If SEARCH is empty and the original file is empty, this indicates creating a new file
|
||||
* (pure insertion).
|
||||
* - If SEARCH is empty and the original file is not empty, this indicates a complete
|
||||
* file replacement (the entire original content is considered matched and replaced).
|
||||
*
|
||||
* 4. Applying Changes:
|
||||
* - Before encountering the "=======" marker, lines are accumulated as search content.
|
||||
* - After "=======" and before ">>>>>>> REPLACE", lines are accumulated as replacement content.
|
||||
* - Once the block is complete (">>>>>>> REPLACE"), the matched section in the original
|
||||
* file is replaced with the accumulated replacement lines, and the position in the original
|
||||
* file is advanced.
|
||||
*
|
||||
* 5. Incremental Output:
|
||||
* - As soon as the match location is found and we are in the REPLACE section, each new
|
||||
* replacement line is appended to the result so that partial updates can be viewed
|
||||
* incrementally.
|
||||
*
|
||||
* 6. Partial Markers:
|
||||
* - If the final line of the chunk looks like it might be part of a marker but is not one
|
||||
* of the known markers, it is removed. This prevents incomplete or partial markers
|
||||
* from corrupting the output.
|
||||
*
|
||||
* 7. Finalization:
|
||||
* - Once all chunks have been processed (when `isFinal` is true), any remaining original
|
||||
* content after the last replaced section is appended to the result.
|
||||
* - Trailing newlines are not forcibly added. The code tries to output exactly what is specified.
|
||||
*
|
||||
* Errors:
|
||||
* - If the search block cannot be matched using any of the available matching strategies,
|
||||
* an error is thrown.
|
||||
*/
|
||||
export async function constructNewFileContent(
|
||||
diffContent: string,
|
||||
originalContent: string,
|
||||
isFinal: boolean,
|
||||
version: "v1" | "v2" = "v1",
|
||||
): Promise<string> {
|
||||
const constructor = constructNewFileContentVersionMapping[version]
|
||||
if (!constructor) {
|
||||
throw new Error(`Invalid version '${version}' for file content constructor`)
|
||||
}
|
||||
return constructor(diffContent, originalContent, isFinal)
|
||||
}
|
||||
|
||||
const constructNewFileContentVersionMapping: Record<
|
||||
string,
|
||||
(diffContent: string, originalContent: string, isFinal: boolean) => Promise<string>
|
||||
> = {
|
||||
v1: constructNewFileContentV1,
|
||||
v2: constructNewFileContentV2,
|
||||
} as const
|
||||
|
||||
async function constructNewFileContentV1(diffContent: string, originalContent: string, isFinal: boolean): Promise<string> {
|
||||
let result = ""
|
||||
let lastProcessedIndex = 0
|
||||
|
||||
let currentSearchContent = ""
|
||||
let currentReplaceContent = ""
|
||||
let inSearch = false
|
||||
let inReplace = false
|
||||
|
||||
let searchMatchIndex = -1
|
||||
let searchEndIndex = -1
|
||||
|
||||
// Track all replacements to handle out-of-order edits
|
||||
let replacements: Array<{ start: number; end: number; content: string }> = []
|
||||
let pendingOutOfOrderReplacement = false
|
||||
|
||||
let lines = diffContent.split("\n")
|
||||
|
||||
// If the last line looks like a partial marker but isn't recognized,
|
||||
// remove it because it might be incomplete.
|
||||
const lastLine = lines[lines.length - 1]
|
||||
if (
|
||||
lines.length > 0 &&
|
||||
(lastLine.startsWith(SEARCH_BLOCK_CHAR) ||
|
||||
lastLine.startsWith(LEGACY_SEARCH_BLOCK_CHAR) ||
|
||||
lastLine.startsWith("=") ||
|
||||
lastLine.startsWith(REPLACE_BLOCK_CHAR) ||
|
||||
lastLine.startsWith(LEGACY_REPLACE_BLOCK_CHAR)) &&
|
||||
!isSearchBlockStart(lastLine) &&
|
||||
!isSearchBlockEnd(lastLine) &&
|
||||
!isReplaceBlockEnd(lastLine)
|
||||
) {
|
||||
lines.pop()
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
if (isSearchBlockStart(line)) {
|
||||
inSearch = true
|
||||
currentSearchContent = ""
|
||||
currentReplaceContent = ""
|
||||
continue
|
||||
}
|
||||
|
||||
if (isSearchBlockEnd(line)) {
|
||||
inSearch = false
|
||||
inReplace = true
|
||||
|
||||
// Remove trailing linebreak for adding the === marker
|
||||
// if (currentSearchContent.endsWith("\r\n")) {
|
||||
// currentSearchContent = currentSearchContent.slice(0, -2)
|
||||
// } else if (currentSearchContent.endsWith("\n")) {
|
||||
// currentSearchContent = currentSearchContent.slice(0, -1)
|
||||
// }
|
||||
|
||||
if (!currentSearchContent) {
|
||||
// Empty search block
|
||||
if (originalContent.length === 0) {
|
||||
// New file scenario: nothing to match, just start inserting
|
||||
searchMatchIndex = 0
|
||||
searchEndIndex = 0
|
||||
} else {
|
||||
// ERROR: Empty search block with non-empty file indicates malformed SEARCH marker
|
||||
throw new Error(
|
||||
"Empty SEARCH block detected with non-empty file. This usually indicates a malformed SEARCH marker.\n" +
|
||||
"Please ensure your SEARCH marker follows the correct format:\n" +
|
||||
"- Use '------- SEARCH' (7+ dashes + space + SEARCH)\n",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Add check for inefficient full-file search
|
||||
// if (currentSearchContent.trim() === originalContent.trim()) {
|
||||
// throw new Error(
|
||||
// "The SEARCH block contains the entire file content. Please either:\n" +
|
||||
// "1. Use an empty SEARCH block to replace the entire file, or\n" +
|
||||
// "2. Make focused changes to specific parts of the file that need modification.",
|
||||
// )
|
||||
// }
|
||||
|
||||
// Exact search match scenario
|
||||
const exactIndex = originalContent.indexOf(currentSearchContent, lastProcessedIndex)
|
||||
if (exactIndex !== -1) {
|
||||
searchMatchIndex = exactIndex
|
||||
searchEndIndex = exactIndex + currentSearchContent.length
|
||||
} else {
|
||||
// Attempt fallback line-trimmed matching
|
||||
const lineMatch = lineTrimmedFallbackMatch(originalContent, currentSearchContent, lastProcessedIndex)
|
||||
if (lineMatch) {
|
||||
;[searchMatchIndex, searchEndIndex] = lineMatch
|
||||
} else {
|
||||
// Try block anchor fallback for larger blocks
|
||||
const blockMatch = blockAnchorFallbackMatch(originalContent, currentSearchContent, lastProcessedIndex)
|
||||
if (blockMatch) {
|
||||
;[searchMatchIndex, searchEndIndex] = blockMatch
|
||||
} else {
|
||||
// Last resort: search the entire file from the beginning
|
||||
const fullFileIndex = originalContent.indexOf(currentSearchContent, 0)
|
||||
if (fullFileIndex !== -1) {
|
||||
// Found in the file - could be out of order
|
||||
searchMatchIndex = fullFileIndex
|
||||
searchEndIndex = fullFileIndex + currentSearchContent.length
|
||||
if (searchMatchIndex < lastProcessedIndex) {
|
||||
pendingOutOfOrderReplacement = true
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`The SEARCH block:\n${currentSearchContent.trimEnd()}\n...does not match anything in the file.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is an out-of-order replacement
|
||||
if (searchMatchIndex < lastProcessedIndex) {
|
||||
pendingOutOfOrderReplacement = true
|
||||
}
|
||||
|
||||
// For in-order replacements, output everything up to the match location
|
||||
if (!pendingOutOfOrderReplacement) {
|
||||
result += originalContent.slice(lastProcessedIndex, searchMatchIndex)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (isReplaceBlockEnd(line)) {
|
||||
// Finished one replace block
|
||||
|
||||
// Store this replacement
|
||||
replacements.push({
|
||||
start: searchMatchIndex,
|
||||
end: searchEndIndex,
|
||||
content: currentReplaceContent,
|
||||
})
|
||||
|
||||
// If this was an in-order replacement, advance lastProcessedIndex
|
||||
if (!pendingOutOfOrderReplacement) {
|
||||
lastProcessedIndex = searchEndIndex
|
||||
}
|
||||
|
||||
// Reset for next block
|
||||
inSearch = false
|
||||
inReplace = false
|
||||
currentSearchContent = ""
|
||||
currentReplaceContent = ""
|
||||
searchMatchIndex = -1
|
||||
searchEndIndex = -1
|
||||
pendingOutOfOrderReplacement = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Accumulate content for search or replace
|
||||
// (currentReplaceContent is not being used for anything right now since we directly append to result.)
|
||||
// (We artificially add a linebreak since we split on \n at the beginning. In order to not include a trailing linebreak in the final search/result blocks we need to remove it before using them. This allows for partial line matches to be correctly identified.)
|
||||
// NOTE: search/replace blocks must be arranged in the order they appear in the file due to how we build the content using lastProcessedIndex. We also cannot strip the trailing newline since for non-partial lines it would remove the linebreak from the original content. (If we remove end linebreak from search, then we'd also have to remove it from replace but we can't know if it's a partial line or not since the model may be using the line break to indicate the end of the block rather than as part of the search content.) We require the model to output full lines in order for our fallbacks to work as well.
|
||||
if (inSearch) {
|
||||
currentSearchContent += line + "\n"
|
||||
} else if (inReplace) {
|
||||
currentReplaceContent += line + "\n"
|
||||
// Only output replacement lines immediately for in-order replacements
|
||||
if (searchMatchIndex !== -1 && !pendingOutOfOrderReplacement) {
|
||||
result += line + "\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If this is the final chunk, we need to apply all replacements and build the final result
|
||||
if (isFinal) {
|
||||
// Handle the case where we're still in replace mode when processing ends
|
||||
// and this is the final chunk - treat it as if we encountered the REPLACE marker
|
||||
if (inReplace && searchMatchIndex !== -1) {
|
||||
// Store this replacement
|
||||
replacements.push({
|
||||
start: searchMatchIndex,
|
||||
end: searchEndIndex,
|
||||
content: currentReplaceContent,
|
||||
})
|
||||
|
||||
// If this was an in-order replacement, advance lastProcessedIndex
|
||||
if (!pendingOutOfOrderReplacement) {
|
||||
lastProcessedIndex = searchEndIndex
|
||||
}
|
||||
|
||||
// Reset state
|
||||
inSearch = false
|
||||
inReplace = false
|
||||
currentSearchContent = ""
|
||||
currentReplaceContent = ""
|
||||
searchMatchIndex = -1
|
||||
searchEndIndex = -1
|
||||
pendingOutOfOrderReplacement = false
|
||||
}
|
||||
// end of handling missing replace marker
|
||||
|
||||
// Sort replacements by start position
|
||||
replacements.sort((a, b) => a.start - b.start)
|
||||
|
||||
// Rebuild the entire result by applying all replacements
|
||||
result = ""
|
||||
let currentPos = 0
|
||||
|
||||
for (const replacement of replacements) {
|
||||
// Add original content up to this replacement
|
||||
result += originalContent.slice(currentPos, replacement.start)
|
||||
// Add the replacement content
|
||||
result += replacement.content
|
||||
// Move position to after the replaced section
|
||||
currentPos = replacement.end
|
||||
}
|
||||
|
||||
// Add any remaining original content
|
||||
result += originalContent.slice(currentPos)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
enum ProcessingState {
|
||||
Idle = 0,
|
||||
StateSearch = 1 << 0,
|
||||
StateReplace = 1 << 1,
|
||||
}
|
||||
|
||||
class NewFileContentConstructor {
|
||||
private originalContent: string
|
||||
private isFinal: boolean
|
||||
private state: number
|
||||
private pendingNonStandardLines: string[]
|
||||
private result: string
|
||||
private lastProcessedIndex: number
|
||||
private currentSearchContent: string
|
||||
private currentReplaceContent: string
|
||||
private searchMatchIndex: number
|
||||
private searchEndIndex: number
|
||||
|
||||
constructor(originalContent: string, isFinal: boolean) {
|
||||
this.originalContent = originalContent
|
||||
this.isFinal = isFinal
|
||||
this.pendingNonStandardLines = []
|
||||
this.result = ""
|
||||
this.lastProcessedIndex = 0
|
||||
this.state = ProcessingState.Idle
|
||||
this.currentSearchContent = ""
|
||||
this.currentReplaceContent = ""
|
||||
this.searchMatchIndex = -1
|
||||
this.searchEndIndex = -1
|
||||
}
|
||||
|
||||
private resetForNextBlock() {
|
||||
// Reset for next block
|
||||
this.state = ProcessingState.Idle
|
||||
this.currentSearchContent = ""
|
||||
this.currentReplaceContent = ""
|
||||
this.searchMatchIndex = -1
|
||||
this.searchEndIndex = -1
|
||||
}
|
||||
|
||||
private findLastMatchingLineIndex(regx: RegExp, lineLimit: number) {
|
||||
for (let i = lineLimit; i > 0; ) {
|
||||
i--
|
||||
if (this.pendingNonStandardLines[i].match(regx)) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
private updateProcessingState(newState: ProcessingState) {
|
||||
const isValidTransition =
|
||||
(this.state === ProcessingState.Idle && newState === ProcessingState.StateSearch) ||
|
||||
(this.state === ProcessingState.StateSearch && newState === ProcessingState.StateReplace)
|
||||
|
||||
if (!isValidTransition) {
|
||||
throw new Error(
|
||||
`Invalid state transition.\n` +
|
||||
"Valid transitions are:\n" +
|
||||
"- Idle → StateSearch\n" +
|
||||
"- StateSearch → StateReplace",
|
||||
)
|
||||
}
|
||||
|
||||
this.state |= newState
|
||||
}
|
||||
|
||||
private isStateActive(state: ProcessingState): boolean {
|
||||
return (this.state & state) === state
|
||||
}
|
||||
|
||||
private activateReplaceState() {
|
||||
this.updateProcessingState(ProcessingState.StateReplace)
|
||||
}
|
||||
|
||||
private activateSearchState() {
|
||||
this.updateProcessingState(ProcessingState.StateSearch)
|
||||
this.currentSearchContent = ""
|
||||
this.currentReplaceContent = ""
|
||||
}
|
||||
|
||||
private isSearchingActive(): boolean {
|
||||
return this.isStateActive(ProcessingState.StateSearch)
|
||||
}
|
||||
|
||||
private isReplacingActive(): boolean {
|
||||
return this.isStateActive(ProcessingState.StateReplace)
|
||||
}
|
||||
|
||||
private hasPendingNonStandardLines(pendingNonStandardLineLimit: number): boolean {
|
||||
return this.pendingNonStandardLines.length - pendingNonStandardLineLimit < this.pendingNonStandardLines.length
|
||||
}
|
||||
|
||||
public processLine(line: string) {
|
||||
this.internalProcessLine(line, true, this.pendingNonStandardLines.length)
|
||||
}
|
||||
|
||||
public getResult() {
|
||||
// If this is the final chunk, append any remaining original content
|
||||
if (this.isFinal && this.lastProcessedIndex < this.originalContent.length) {
|
||||
this.result += this.originalContent.slice(this.lastProcessedIndex)
|
||||
}
|
||||
if (this.isFinal && this.state !== ProcessingState.Idle) {
|
||||
throw new Error("File processing incomplete - SEARCH/REPLACE operations still active during finalization")
|
||||
}
|
||||
return this.result
|
||||
}
|
||||
|
||||
private internalProcessLine(
|
||||
line: string,
|
||||
canWritependingNonStandardLines: boolean,
|
||||
pendingNonStandardLineLimit: number,
|
||||
): number {
|
||||
let removeLineCount = 0
|
||||
if (isSearchBlockStart(line)) {
|
||||
removeLineCount = this.trimPendingNonStandardTrailingEmptyLines(pendingNonStandardLineLimit)
|
||||
if (removeLineCount > 0) {
|
||||
pendingNonStandardLineLimit = pendingNonStandardLineLimit - removeLineCount
|
||||
}
|
||||
if (this.hasPendingNonStandardLines(pendingNonStandardLineLimit)) {
|
||||
this.tryFixSearchReplaceBlock(pendingNonStandardLineLimit)
|
||||
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
|
||||
}
|
||||
this.activateSearchState()
|
||||
} else if (isSearchBlockEnd(line)) {
|
||||
// 校验非标内容
|
||||
if (!this.isSearchingActive()) {
|
||||
this.tryFixSearchBlock(pendingNonStandardLineLimit)
|
||||
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
|
||||
}
|
||||
this.activateReplaceState()
|
||||
this.beforeReplace()
|
||||
} else if (isReplaceBlockEnd(line)) {
|
||||
if (!this.isReplacingActive()) {
|
||||
this.tryFixReplaceBlock(pendingNonStandardLineLimit)
|
||||
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
|
||||
}
|
||||
this.lastProcessedIndex = this.searchEndIndex
|
||||
this.resetForNextBlock()
|
||||
} else {
|
||||
// Accumulate content for search or replace
|
||||
// (currentReplaceContent is not being used for anything right now since we directly append to result.)
|
||||
// (We artificially add a linebreak since we split on \n at the beginning. In order to not include a trailing linebreak in the final search/result blocks we need to remove it before using them. This allows for partial line matches to be correctly identified.)
|
||||
// NOTE: search/replace blocks must be arranged in the order they appear in the file due to how we build the content using lastProcessedIndex. We also cannot strip the trailing newline since for non-partial lines it would remove the linebreak from the original content. (If we remove end linebreak from search, then we'd also have to remove it from replace but we can't know if it's a partial line or not since the model may be using the line break to indicate the end of the block rather than as part of the search content.) We require the model to output full lines in order for our fallbacks to work as well.
|
||||
if (this.isReplacingActive()) {
|
||||
this.currentReplaceContent += line + "\n"
|
||||
// Output replacement lines immediately if we know the insertion point
|
||||
if (this.searchMatchIndex !== -1) {
|
||||
this.result += line + "\n"
|
||||
}
|
||||
} else if (this.isSearchingActive()) {
|
||||
this.currentSearchContent += line + "\n"
|
||||
} else {
|
||||
let appendToPendingNonStandardLines = canWritependingNonStandardLines
|
||||
if (appendToPendingNonStandardLines) {
|
||||
// 处理非标内容
|
||||
this.pendingNonStandardLines.push(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
return removeLineCount
|
||||
}
|
||||
|
||||
private beforeReplace() {
|
||||
// Remove trailing linebreak for adding the === marker
|
||||
// if (currentSearchContent.endsWith("\r\n")) {
|
||||
// currentSearchContent = currentSearchContent.slice(0, -2)
|
||||
// } else if (currentSearchContent.endsWith("\n")) {
|
||||
// currentSearchContent = currentSearchContent.slice(0, -1)
|
||||
// }
|
||||
|
||||
if (!this.currentSearchContent) {
|
||||
// Empty search block
|
||||
if (this.originalContent.length === 0) {
|
||||
// New file scenario: nothing to match, just start inserting
|
||||
this.searchMatchIndex = 0
|
||||
this.searchEndIndex = 0
|
||||
} else {
|
||||
// Complete file replacement scenario: treat the entire file as matched
|
||||
this.searchMatchIndex = 0
|
||||
this.searchEndIndex = this.originalContent.length
|
||||
}
|
||||
} else {
|
||||
// Add check for inefficient full-file search
|
||||
// if (currentSearchContent.trim() === originalContent.trim()) {
|
||||
// throw new Error(
|
||||
// "The SEARCH block contains the entire file content. Please either:\n" +
|
||||
// "1. Use an empty SEARCH block to replace the entire file, or\n" +
|
||||
// "2. Make focused changes to specific parts of the file that need modification.",
|
||||
// )
|
||||
// }
|
||||
// Exact search match scenario
|
||||
const exactIndex = this.originalContent.indexOf(this.currentSearchContent, this.lastProcessedIndex)
|
||||
if (exactIndex !== -1) {
|
||||
this.searchMatchIndex = exactIndex
|
||||
this.searchEndIndex = exactIndex + this.currentSearchContent.length
|
||||
} else {
|
||||
// Attempt fallback line-trimmed matching
|
||||
const lineMatch = lineTrimmedFallbackMatch(
|
||||
this.originalContent,
|
||||
this.currentSearchContent,
|
||||
this.lastProcessedIndex,
|
||||
)
|
||||
if (lineMatch) {
|
||||
;[this.searchMatchIndex, this.searchEndIndex] = lineMatch
|
||||
} else {
|
||||
// Try block anchor fallback for larger blocks
|
||||
const blockMatch = blockAnchorFallbackMatch(
|
||||
this.originalContent,
|
||||
this.currentSearchContent,
|
||||
this.lastProcessedIndex,
|
||||
)
|
||||
if (blockMatch) {
|
||||
;[this.searchMatchIndex, this.searchEndIndex] = blockMatch
|
||||
} else {
|
||||
throw new Error(
|
||||
`The SEARCH block:\n${this.currentSearchContent.trimEnd()}\n...does not match anything in the file.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.searchMatchIndex < this.lastProcessedIndex) {
|
||||
throw new Error(
|
||||
`The SEARCH block:\n${this.currentSearchContent.trimEnd()}\n...matched an incorrect content in the file.`,
|
||||
)
|
||||
}
|
||||
// Output everything up to the match location
|
||||
this.result += this.originalContent.slice(this.lastProcessedIndex, this.searchMatchIndex)
|
||||
}
|
||||
|
||||
private tryFixSearchBlock(lineLimit: number): number {
|
||||
let removeLineCount = 0
|
||||
if (lineLimit < 0) {
|
||||
lineLimit = this.pendingNonStandardLines.length
|
||||
}
|
||||
if (!lineLimit) {
|
||||
throw new Error("Invalid SEARCH/REPLACE block structure - no lines available to process")
|
||||
}
|
||||
let searchTagRegexp = /^([-]{3,}|[<]{3,}) SEARCH$/
|
||||
const searchTagIndex = this.findLastMatchingLineIndex(searchTagRegexp, lineLimit)
|
||||
if (searchTagIndex !== -1) {
|
||||
let fixLines = this.pendingNonStandardLines.slice(searchTagIndex, lineLimit)
|
||||
fixLines[0] = SEARCH_BLOCK_START
|
||||
for (const line of fixLines) {
|
||||
removeLineCount += this.internalProcessLine(line, false, searchTagIndex)
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`Invalid REPLACE marker detected - could not find matching SEARCH block starting from line ${searchTagIndex + 1}`,
|
||||
)
|
||||
}
|
||||
return removeLineCount
|
||||
}
|
||||
|
||||
private tryFixReplaceBlock(lineLimit: number): number {
|
||||
let removeLineCount = 0
|
||||
if (lineLimit < 0) {
|
||||
lineLimit = this.pendingNonStandardLines.length
|
||||
}
|
||||
if (!lineLimit) {
|
||||
throw new Error()
|
||||
}
|
||||
let replaceBeginTagRegexp = /^[=]{3,}$/
|
||||
const replaceBeginTagIndex = this.findLastMatchingLineIndex(replaceBeginTagRegexp, lineLimit)
|
||||
if (replaceBeginTagIndex !== -1) {
|
||||
// // 校验非标内容
|
||||
// if (!this.isSearchingActive()) {
|
||||
// removeLineCount += this.tryFixSearchBlock(replaceBeginTagIndex)
|
||||
// }
|
||||
let fixLines = this.pendingNonStandardLines.slice(replaceBeginTagIndex - removeLineCount, lineLimit - removeLineCount)
|
||||
fixLines[0] = SEARCH_BLOCK_END
|
||||
for (const line of fixLines) {
|
||||
removeLineCount += this.internalProcessLine(line, false, replaceBeginTagIndex - removeLineCount)
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Malformed REPLACE block - missing valid separator after line ${replaceBeginTagIndex + 1}`)
|
||||
}
|
||||
return removeLineCount
|
||||
}
|
||||
|
||||
private tryFixSearchReplaceBlock(lineLimit: number): number {
|
||||
let removeLineCount = 0
|
||||
if (lineLimit < 0) {
|
||||
lineLimit = this.pendingNonStandardLines.length
|
||||
}
|
||||
if (!lineLimit) {
|
||||
throw new Error()
|
||||
}
|
||||
|
||||
let replaceEndTagRegexp = /^([+]{3,}|[>]{3,}) REPLACE$/
|
||||
const replaceEndTagIndex = this.findLastMatchingLineIndex(replaceEndTagRegexp, lineLimit)
|
||||
const likeReplaceEndTag = replaceEndTagIndex === lineLimit - 1
|
||||
if (likeReplaceEndTag) {
|
||||
// // 校验非标内容
|
||||
// if (!this.isReplacingActive()) {
|
||||
// removeLineCount += this.tryFixReplaceBlock(replaceEndTagIndex)
|
||||
// }
|
||||
let fixLines = this.pendingNonStandardLines.slice(replaceEndTagIndex - removeLineCount, lineLimit - removeLineCount)
|
||||
fixLines[fixLines.length - 1] = REPLACE_BLOCK_END
|
||||
for (const line of fixLines) {
|
||||
removeLineCount += this.internalProcessLine(line, false, replaceEndTagIndex - removeLineCount)
|
||||
}
|
||||
} else {
|
||||
throw new Error("Malformed SEARCH/REPLACE block structure: Missing valid closing REPLACE marker")
|
||||
}
|
||||
return removeLineCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes trailing empty lines from the pendingNonStandardLines array
|
||||
* @param lineLimit - The index to start checking from (exclusive).
|
||||
* Removes empty lines from lineLimit-1 backwards.
|
||||
* @returns The number of empty lines removed
|
||||
*/
|
||||
private trimPendingNonStandardTrailingEmptyLines(lineLimit: number): number {
|
||||
let removedCount = 0
|
||||
let i = Math.min(lineLimit, this.pendingNonStandardLines.length) - 1
|
||||
|
||||
while (i >= 0 && this.pendingNonStandardLines[i].trim() === "") {
|
||||
this.pendingNonStandardLines.pop()
|
||||
removedCount++
|
||||
i--
|
||||
}
|
||||
|
||||
return removedCount
|
||||
}
|
||||
}
|
||||
|
||||
export async function constructNewFileContentV2(diffContent: string, originalContent: string, isFinal: boolean): Promise<string> {
|
||||
let newFileContentConstructor = new NewFileContentConstructor(originalContent, isFinal)
|
||||
|
||||
let lines = diffContent.split("\n")
|
||||
|
||||
// If the last line looks like a partial marker but isn't recognized,
|
||||
// remove it because it might be incomplete.
|
||||
const lastLine = lines[lines.length - 1]
|
||||
if (
|
||||
lines.length > 0 &&
|
||||
(lastLine.startsWith(SEARCH_BLOCK_CHAR) ||
|
||||
lastLine.startsWith(LEGACY_SEARCH_BLOCK_CHAR) ||
|
||||
lastLine.startsWith("=") ||
|
||||
lastLine.startsWith(REPLACE_BLOCK_CHAR) ||
|
||||
lastLine.startsWith(LEGACY_REPLACE_BLOCK_CHAR)) &&
|
||||
lastLine !== SEARCH_BLOCK_START &&
|
||||
lastLine !== SEARCH_BLOCK_END &&
|
||||
lastLine !== REPLACE_BLOCK_END
|
||||
) {
|
||||
lines.pop()
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
newFileContentConstructor.processLine(line)
|
||||
}
|
||||
|
||||
let result = newFileContentConstructor.getResult()
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,829 @@
|
||||
const SEARCH_BLOCK_START = "------- SEARCH"
|
||||
const SEARCH_BLOCK_END = "======="
|
||||
const REPLACE_BLOCK_END = "+++++++ REPLACE"
|
||||
|
||||
const SEARCH_BLOCK_CHAR = "-"
|
||||
const REPLACE_BLOCK_CHAR = "+"
|
||||
const LEGACY_SEARCH_BLOCK_CHAR = "<"
|
||||
const LEGACY_REPLACE_BLOCK_CHAR = ">"
|
||||
|
||||
// Replace the exact string constants with flexible regex patterns
|
||||
const SEARCH_BLOCK_START_REGEX = /^[-]{3,} SEARCH>?$/
|
||||
const LEGACY_SEARCH_BLOCK_START_REGEX = /^[<]{3,} SEARCH>?$/
|
||||
|
||||
const SEARCH_BLOCK_END_REGEX = /^[=]{3,}$/
|
||||
|
||||
const REPLACE_BLOCK_END_REGEX = /^[+]{3,} REPLACE>?$/
|
||||
const LEGACY_REPLACE_BLOCK_END_REGEX = /^[>]{3,} REPLACE>?$/
|
||||
|
||||
// Helper functions to check if a line matches the flexible patterns
|
||||
function isSearchBlockStart(line: string): boolean {
|
||||
return SEARCH_BLOCK_START_REGEX.test(line) || LEGACY_SEARCH_BLOCK_START_REGEX.test(line)
|
||||
}
|
||||
|
||||
function isSearchBlockEnd(line: string): boolean {
|
||||
return SEARCH_BLOCK_END_REGEX.test(line)
|
||||
}
|
||||
|
||||
function isReplaceBlockEnd(line: string): boolean {
|
||||
return REPLACE_BLOCK_END_REGEX.test(line) || LEGACY_REPLACE_BLOCK_END_REGEX.test(line)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts a line-trimmed fallback match for the given search content in the original content.
|
||||
* It tries to match `searchContent` lines against a block of lines in `originalContent` starting
|
||||
* from `lastProcessedIndex`. Lines are matched by trimming leading/trailing whitespace and ensuring
|
||||
* they are identical afterwards.
|
||||
*
|
||||
* Returns [matchIndexStart, matchIndexEnd] if found, or false if not found.
|
||||
*/
|
||||
function lineTrimmedFallbackMatch(originalContent: string, searchContent: string, startIndex: number): [number, number] | false {
|
||||
// Split both contents into lines
|
||||
const originalLines = originalContent.split("\n")
|
||||
const searchLines = searchContent.split("\n")
|
||||
|
||||
// Trim trailing empty line if exists (from the trailing \n in searchContent)
|
||||
if (searchLines[searchLines.length - 1] === "") {
|
||||
searchLines.pop()
|
||||
}
|
||||
|
||||
// Find the line number where startIndex falls
|
||||
let startLineNum = 0
|
||||
let currentIndex = 0
|
||||
while (currentIndex < startIndex && startLineNum < originalLines.length) {
|
||||
currentIndex += originalLines[startLineNum].length + 1 // +1 for \n
|
||||
startLineNum++
|
||||
}
|
||||
|
||||
// For each possible starting position in original content
|
||||
for (let i = startLineNum; i <= originalLines.length - searchLines.length; i++) {
|
||||
let matches = true
|
||||
|
||||
// Try to match all search lines from this position
|
||||
for (let j = 0; j < searchLines.length; j++) {
|
||||
const originalTrimmed = originalLines[i + j].trim()
|
||||
const searchTrimmed = searchLines[j].trim()
|
||||
|
||||
if (originalTrimmed !== searchTrimmed) {
|
||||
matches = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a match, calculate the exact character positions
|
||||
if (matches) {
|
||||
// Find start character index
|
||||
let matchStartIndex = 0
|
||||
for (let k = 0; k < i; k++) {
|
||||
matchStartIndex += originalLines[k].length + 1 // +1 for \n
|
||||
}
|
||||
|
||||
// Find end character index
|
||||
let matchEndIndex = matchStartIndex
|
||||
for (let k = 0; k < searchLines.length; k++) {
|
||||
matchEndIndex += originalLines[i + k].length + 1 // +1 for \n
|
||||
}
|
||||
|
||||
return [matchStartIndex, matchEndIndex]
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to match blocks of code by using the first and last lines as anchors.
|
||||
* This is a third-tier fallback strategy that helps match blocks where we can identify
|
||||
* the correct location by matching the beginning and end, even if the exact content
|
||||
* differs slightly.
|
||||
*
|
||||
* The matching strategy:
|
||||
* 1. Only attempts to match blocks of 3 or more lines to avoid false positives
|
||||
* 2. Extracts from the search content:
|
||||
* - First line as the "start anchor"
|
||||
* - Last line as the "end anchor"
|
||||
* 3. For each position in the original content:
|
||||
* - Checks if the next line matches the start anchor
|
||||
* - If it does, jumps ahead by the search block size
|
||||
* - Checks if that line matches the end anchor
|
||||
* - All comparisons are done after trimming whitespace
|
||||
*
|
||||
* This approach is particularly useful for matching blocks of code where:
|
||||
* - The exact content might have minor differences
|
||||
* - The beginning and end of the block are distinctive enough to serve as anchors
|
||||
* - The overall structure (number of lines) remains the same
|
||||
*
|
||||
* @param originalContent - The full content of the original file
|
||||
* @param searchContent - The content we're trying to find in the original file
|
||||
* @param startIndex - The character index in originalContent where to start searching
|
||||
* @returns A tuple of [startIndex, endIndex] if a match is found, false otherwise
|
||||
*/
|
||||
function blockAnchorFallbackMatch(originalContent: string, searchContent: string, startIndex: number): [number, number] | false {
|
||||
const originalLines = originalContent.split("\n")
|
||||
const searchLines = searchContent.split("\n")
|
||||
|
||||
// Only use this approach for blocks of 3+ lines
|
||||
if (searchLines.length < 3) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Trim trailing empty line if exists
|
||||
if (searchLines[searchLines.length - 1] === "") {
|
||||
searchLines.pop()
|
||||
}
|
||||
|
||||
const firstLineSearch = searchLines[0].trim()
|
||||
const lastLineSearch = searchLines[searchLines.length - 1].trim()
|
||||
const searchBlockSize = searchLines.length
|
||||
|
||||
// Find the line number where startIndex falls
|
||||
let startLineNum = 0
|
||||
let currentIndex = 0
|
||||
while (currentIndex < startIndex && startLineNum < originalLines.length) {
|
||||
currentIndex += originalLines[startLineNum].length + 1
|
||||
startLineNum++
|
||||
}
|
||||
|
||||
// Look for matching start and end anchors
|
||||
for (let i = startLineNum; i <= originalLines.length - searchBlockSize; i++) {
|
||||
// Check if first line matches
|
||||
if (originalLines[i].trim() !== firstLineSearch) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if last line matches at the expected position
|
||||
if (originalLines[i + searchBlockSize - 1].trim() !== lastLineSearch) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate exact character positions
|
||||
let matchStartIndex = 0
|
||||
for (let k = 0; k < i; k++) {
|
||||
matchStartIndex += originalLines[k].length + 1
|
||||
}
|
||||
|
||||
let matchEndIndex = matchStartIndex
|
||||
for (let k = 0; k < searchBlockSize; k++) {
|
||||
matchEndIndex += originalLines[i + k].length + 1
|
||||
}
|
||||
|
||||
return [matchStartIndex, matchEndIndex]
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* This function reconstructs the file content by applying a streamed diff (in a
|
||||
* specialized SEARCH/REPLACE block format) to the original file content. It is designed
|
||||
* to handle both incremental updates and the final resulting file after all chunks have
|
||||
* been processed.
|
||||
*
|
||||
* The diff format is a custom structure that uses three markers to define changes:
|
||||
*
|
||||
* ------- SEARCH
|
||||
* [Exact content to find in the original file]
|
||||
* =======
|
||||
* [Content to replace with]
|
||||
* +++++++ REPLACE
|
||||
*
|
||||
* Behavior and Assumptions:
|
||||
* 1. The file is processed chunk-by-chunk. Each chunk of `diffContent` may contain
|
||||
* partial or complete SEARCH/REPLACE blocks. By calling this function with each
|
||||
* incremental chunk (with `isFinal` indicating the last chunk), the final reconstructed
|
||||
* file content is produced.
|
||||
*
|
||||
* 2. Matching Strategy (in order of attempt):
|
||||
* a. Exact Match: First attempts to find the exact SEARCH block text in the original file
|
||||
* b. Line-Trimmed Match: Falls back to line-by-line comparison ignoring leading/trailing whitespace
|
||||
* c. Block Anchor Match: For blocks of 3+ lines, tries to match using first/last lines as anchors
|
||||
* If all matching strategies fail, an error is thrown.
|
||||
*
|
||||
* 3. Empty SEARCH Section:
|
||||
* - If SEARCH is empty and the original file is empty, this indicates creating a new file
|
||||
* (pure insertion).
|
||||
* - If SEARCH is empty and the original file is not empty, this indicates a complete
|
||||
* file replacement (the entire original content is considered matched and replaced).
|
||||
*
|
||||
* 4. Applying Changes:
|
||||
* - Before encountering the "=======" marker, lines are accumulated as search content.
|
||||
* - After "=======" and before ">>>>>>> REPLACE", lines are accumulated as replacement content.
|
||||
* - Once the block is complete (">>>>>>> REPLACE"), the matched section in the original
|
||||
* file is replaced with the accumulated replacement lines, and the position in the original
|
||||
* file is advanced.
|
||||
*
|
||||
* 5. Incremental Output:
|
||||
* - As soon as the match location is found and we are in the REPLACE section, each new
|
||||
* replacement line is appended to the result so that partial updates can be viewed
|
||||
* incrementally.
|
||||
*
|
||||
* 6. Partial Markers:
|
||||
* - If the final line of the chunk looks like it might be part of a marker but is not one
|
||||
* of the known markers, it is removed. This prevents incomplete or partial markers
|
||||
* from corrupting the output.
|
||||
*
|
||||
* 7. Finalization:
|
||||
* - Once all chunks have been processed (when `isFinal` is true), any remaining original
|
||||
* content after the last replaced section is appended to the result.
|
||||
* - Trailing newlines are not forcibly added. The code tries to output exactly what is specified.
|
||||
*
|
||||
* Errors:
|
||||
* - If the search block cannot be matched using any of the available matching strategies,
|
||||
* an error is thrown.
|
||||
*/
|
||||
export async function constructNewFileContent(
|
||||
diffContent: string,
|
||||
originalContent: string,
|
||||
isFinal: boolean,
|
||||
version: "v1" | "v2" = "v1",
|
||||
): Promise<string> {
|
||||
const constructor = constructNewFileContentVersionMapping[version]
|
||||
if (!constructor) {
|
||||
throw new Error(`Invalid version '${version}' for file content constructor`)
|
||||
}
|
||||
return constructor(diffContent, originalContent, isFinal)
|
||||
}
|
||||
|
||||
const constructNewFileContentVersionMapping: Record<
|
||||
string,
|
||||
(diffContent: string, originalContent: string, isFinal: boolean) => Promise<string>
|
||||
> = {
|
||||
v1: constructNewFileContentV1,
|
||||
v2: constructNewFileContentV2,
|
||||
} as const
|
||||
|
||||
async function constructNewFileContentV1(diffContent: string, originalContent: string, isFinal: boolean): Promise<string> {
|
||||
let result = ""
|
||||
let lastProcessedIndex = 0
|
||||
|
||||
let currentSearchContent = ""
|
||||
let currentReplaceContent = ""
|
||||
let inSearch = false
|
||||
let inReplace = false
|
||||
|
||||
let searchMatchIndex = -1
|
||||
let searchEndIndex = -1
|
||||
|
||||
// Track all replacements to handle out-of-order edits
|
||||
let replacements: Array<{ start: number; end: number; content: string }> = []
|
||||
let pendingOutOfOrderReplacement = false
|
||||
|
||||
let lines = diffContent.split("\n")
|
||||
|
||||
// If the last line looks like a partial marker but isn't recognized,
|
||||
// remove it because it might be incomplete.
|
||||
const lastLine = lines[lines.length - 1]
|
||||
if (
|
||||
lines.length > 0 &&
|
||||
(lastLine.startsWith(SEARCH_BLOCK_CHAR) ||
|
||||
lastLine.startsWith(LEGACY_SEARCH_BLOCK_CHAR) ||
|
||||
lastLine.startsWith("=") ||
|
||||
lastLine.startsWith(REPLACE_BLOCK_CHAR) ||
|
||||
lastLine.startsWith(LEGACY_REPLACE_BLOCK_CHAR)) &&
|
||||
!isSearchBlockStart(lastLine) &&
|
||||
!isSearchBlockEnd(lastLine) &&
|
||||
!isReplaceBlockEnd(lastLine)
|
||||
) {
|
||||
lines.pop()
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
if (isSearchBlockStart(line)) {
|
||||
inSearch = true
|
||||
currentSearchContent = ""
|
||||
currentReplaceContent = ""
|
||||
continue
|
||||
}
|
||||
|
||||
if (isSearchBlockEnd(line)) {
|
||||
inSearch = false
|
||||
inReplace = true
|
||||
|
||||
// Remove trailing linebreak for adding the === marker
|
||||
// if (currentSearchContent.endsWith("\r\n")) {
|
||||
// currentSearchContent = currentSearchContent.slice(0, -2)
|
||||
// } else if (currentSearchContent.endsWith("\n")) {
|
||||
// currentSearchContent = currentSearchContent.slice(0, -1)
|
||||
// }
|
||||
|
||||
if (!currentSearchContent) {
|
||||
// Empty search block
|
||||
if (originalContent.length === 0) {
|
||||
// New file scenario: nothing to match, just start inserting
|
||||
searchMatchIndex = 0
|
||||
searchEndIndex = 0
|
||||
} else {
|
||||
// ERROR: Empty search block with non-empty file indicates malformed SEARCH marker
|
||||
throw new Error(
|
||||
"Empty SEARCH block detected with non-empty file. This usually indicates a malformed SEARCH marker.\n" +
|
||||
"Please ensure your SEARCH marker follows the correct format:\n" +
|
||||
"- Use '------- SEARCH' (7+ dashes + space + SEARCH)\n",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Add check for inefficient full-file search
|
||||
// if (currentSearchContent.trim() === originalContent.trim()) {
|
||||
// throw new Error(
|
||||
// "The SEARCH block contains the entire file content. Please either:\n" +
|
||||
// "1. Use an empty SEARCH block to replace the entire file, or\n" +
|
||||
// "2. Make focused changes to specific parts of the file that need modification.",
|
||||
// )
|
||||
// }
|
||||
|
||||
// Exact search match scenario
|
||||
const exactIndex = originalContent.indexOf(currentSearchContent, lastProcessedIndex)
|
||||
if (exactIndex !== -1) {
|
||||
searchMatchIndex = exactIndex
|
||||
searchEndIndex = exactIndex + currentSearchContent.length
|
||||
} else {
|
||||
// Attempt fallback line-trimmed matching
|
||||
const lineMatch = lineTrimmedFallbackMatch(originalContent, currentSearchContent, lastProcessedIndex)
|
||||
if (lineMatch) {
|
||||
;[searchMatchIndex, searchEndIndex] = lineMatch
|
||||
} else {
|
||||
// Try block anchor fallback for larger blocks
|
||||
const blockMatch = blockAnchorFallbackMatch(originalContent, currentSearchContent, lastProcessedIndex)
|
||||
if (blockMatch) {
|
||||
;[searchMatchIndex, searchEndIndex] = blockMatch
|
||||
} else {
|
||||
// Last resort: search the entire file from the beginning
|
||||
const fullFileIndex = originalContent.indexOf(currentSearchContent, 0)
|
||||
if (fullFileIndex !== -1) {
|
||||
// Found in the file - could be out of order
|
||||
searchMatchIndex = fullFileIndex
|
||||
searchEndIndex = fullFileIndex + currentSearchContent.length
|
||||
if (searchMatchIndex < lastProcessedIndex) {
|
||||
pendingOutOfOrderReplacement = true
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`The SEARCH block:\n${currentSearchContent.trimEnd()}\n...does not match anything in the file.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is an out-of-order replacement
|
||||
if (searchMatchIndex < lastProcessedIndex) {
|
||||
pendingOutOfOrderReplacement = true
|
||||
}
|
||||
|
||||
// For in-order replacements, output everything up to the match location
|
||||
if (!pendingOutOfOrderReplacement) {
|
||||
result += originalContent.slice(lastProcessedIndex, searchMatchIndex)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (isReplaceBlockEnd(line)) {
|
||||
// Finished one replace block
|
||||
|
||||
// Store this replacement
|
||||
replacements.push({
|
||||
start: searchMatchIndex,
|
||||
end: searchEndIndex,
|
||||
content: currentReplaceContent,
|
||||
})
|
||||
|
||||
// If this was an in-order replacement, advance lastProcessedIndex
|
||||
if (!pendingOutOfOrderReplacement) {
|
||||
lastProcessedIndex = searchEndIndex
|
||||
}
|
||||
|
||||
// Reset for next block
|
||||
inSearch = false
|
||||
inReplace = false
|
||||
currentSearchContent = ""
|
||||
currentReplaceContent = ""
|
||||
searchMatchIndex = -1
|
||||
searchEndIndex = -1
|
||||
pendingOutOfOrderReplacement = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Accumulate content for search or replace
|
||||
// (currentReplaceContent is not being used for anything right now since we directly append to result.)
|
||||
// (We artificially add a linebreak since we split on \n at the beginning. In order to not include a trailing linebreak in the final search/result blocks we need to remove it before using them. This allows for partial line matches to be correctly identified.)
|
||||
// NOTE: search/replace blocks must be arranged in the order they appear in the file due to how we build the content using lastProcessedIndex. We also cannot strip the trailing newline since for non-partial lines it would remove the linebreak from the original content. (If we remove end linebreak from search, then we'd also have to remove it from replace but we can't know if it's a partial line or not since the model may be using the line break to indicate the end of the block rather than as part of the search content.) We require the model to output full lines in order for our fallbacks to work as well.
|
||||
if (inSearch) {
|
||||
currentSearchContent += line + "\n"
|
||||
} else if (inReplace) {
|
||||
currentReplaceContent += line + "\n"
|
||||
// Only output replacement lines immediately for in-order replacements
|
||||
if (searchMatchIndex !== -1 && !pendingOutOfOrderReplacement) {
|
||||
result += line + "\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If this is the final chunk, we need to apply all replacements and build the final result
|
||||
if (isFinal) {
|
||||
// Handle the case where we're still in replace mode when processing ends
|
||||
// and this is the final chunk - treat it as if we encountered the REPLACE marker
|
||||
if (inReplace && searchMatchIndex !== -1) {
|
||||
// Store this replacement
|
||||
replacements.push({
|
||||
start: searchMatchIndex,
|
||||
end: searchEndIndex,
|
||||
content: currentReplaceContent,
|
||||
})
|
||||
|
||||
// If this was an in-order replacement, advance lastProcessedIndex
|
||||
if (!pendingOutOfOrderReplacement) {
|
||||
lastProcessedIndex = searchEndIndex
|
||||
}
|
||||
|
||||
// Reset state
|
||||
inSearch = false
|
||||
inReplace = false
|
||||
currentSearchContent = ""
|
||||
currentReplaceContent = ""
|
||||
searchMatchIndex = -1
|
||||
searchEndIndex = -1
|
||||
pendingOutOfOrderReplacement = false
|
||||
}
|
||||
// end of handling missing replace marker
|
||||
|
||||
// Sort replacements by start position
|
||||
replacements.sort((a, b) => a.start - b.start)
|
||||
|
||||
// Rebuild the entire result by applying all replacements
|
||||
result = ""
|
||||
let currentPos = 0
|
||||
|
||||
for (const replacement of replacements) {
|
||||
// Add original content up to this replacement
|
||||
result += originalContent.slice(currentPos, replacement.start)
|
||||
// Add the replacement content
|
||||
result += replacement.content
|
||||
// Move position to after the replaced section
|
||||
currentPos = replacement.end
|
||||
}
|
||||
|
||||
// Add any remaining original content
|
||||
result += originalContent.slice(currentPos)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
enum ProcessingState {
|
||||
Idle = 0,
|
||||
StateSearch = 1 << 0,
|
||||
StateReplace = 1 << 1,
|
||||
}
|
||||
|
||||
class NewFileContentConstructor {
|
||||
private originalContent: string
|
||||
private isFinal: boolean
|
||||
private state: number
|
||||
private pendingNonStandardLines: string[]
|
||||
private result: string
|
||||
private lastProcessedIndex: number
|
||||
private currentSearchContent: string
|
||||
private currentReplaceContent: string
|
||||
private searchMatchIndex: number
|
||||
private searchEndIndex: number
|
||||
|
||||
constructor(originalContent: string, isFinal: boolean) {
|
||||
this.originalContent = originalContent
|
||||
this.isFinal = isFinal
|
||||
this.pendingNonStandardLines = []
|
||||
this.result = ""
|
||||
this.lastProcessedIndex = 0
|
||||
this.state = ProcessingState.Idle
|
||||
this.currentSearchContent = ""
|
||||
this.currentReplaceContent = ""
|
||||
this.searchMatchIndex = -1
|
||||
this.searchEndIndex = -1
|
||||
}
|
||||
|
||||
private resetForNextBlock() {
|
||||
// Reset for next block
|
||||
this.state = ProcessingState.Idle
|
||||
this.currentSearchContent = ""
|
||||
this.currentReplaceContent = ""
|
||||
this.searchMatchIndex = -1
|
||||
this.searchEndIndex = -1
|
||||
}
|
||||
|
||||
private findLastMatchingLineIndex(regx: RegExp, lineLimit: number) {
|
||||
for (let i = lineLimit; i > 0; ) {
|
||||
i--
|
||||
if (this.pendingNonStandardLines[i].match(regx)) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
private updateProcessingState(newState: ProcessingState) {
|
||||
const isValidTransition =
|
||||
(this.state === ProcessingState.Idle && newState === ProcessingState.StateSearch) ||
|
||||
(this.state === ProcessingState.StateSearch && newState === ProcessingState.StateReplace)
|
||||
|
||||
if (!isValidTransition) {
|
||||
throw new Error(
|
||||
`Invalid state transition.\n` +
|
||||
"Valid transitions are:\n" +
|
||||
"- Idle → StateSearch\n" +
|
||||
"- StateSearch → StateReplace",
|
||||
)
|
||||
}
|
||||
|
||||
this.state |= newState
|
||||
}
|
||||
|
||||
private isStateActive(state: ProcessingState): boolean {
|
||||
return (this.state & state) === state
|
||||
}
|
||||
|
||||
private activateReplaceState() {
|
||||
this.updateProcessingState(ProcessingState.StateReplace)
|
||||
}
|
||||
|
||||
private activateSearchState() {
|
||||
this.updateProcessingState(ProcessingState.StateSearch)
|
||||
this.currentSearchContent = ""
|
||||
this.currentReplaceContent = ""
|
||||
}
|
||||
|
||||
private isSearchingActive(): boolean {
|
||||
return this.isStateActive(ProcessingState.StateSearch)
|
||||
}
|
||||
|
||||
private isReplacingActive(): boolean {
|
||||
return this.isStateActive(ProcessingState.StateReplace)
|
||||
}
|
||||
|
||||
private hasPendingNonStandardLines(pendingNonStandardLineLimit: number): boolean {
|
||||
return this.pendingNonStandardLines.length - pendingNonStandardLineLimit < this.pendingNonStandardLines.length
|
||||
}
|
||||
|
||||
public processLine(line: string) {
|
||||
this.internalProcessLine(line, true, this.pendingNonStandardLines.length)
|
||||
}
|
||||
|
||||
public getResult() {
|
||||
// If this is the final chunk, append any remaining original content
|
||||
if (this.isFinal && this.lastProcessedIndex < this.originalContent.length) {
|
||||
this.result += this.originalContent.slice(this.lastProcessedIndex)
|
||||
}
|
||||
if (this.isFinal && this.state !== ProcessingState.Idle) {
|
||||
throw new Error("File processing incomplete - SEARCH/REPLACE operations still active during finalization")
|
||||
}
|
||||
return this.result
|
||||
}
|
||||
|
||||
private internalProcessLine(
|
||||
line: string,
|
||||
canWritependingNonStandardLines: boolean,
|
||||
pendingNonStandardLineLimit: number,
|
||||
): number {
|
||||
let removeLineCount = 0
|
||||
if (isSearchBlockStart(line)) {
|
||||
removeLineCount = this.trimPendingNonStandardTrailingEmptyLines(pendingNonStandardLineLimit)
|
||||
if (removeLineCount > 0) {
|
||||
pendingNonStandardLineLimit = pendingNonStandardLineLimit - removeLineCount
|
||||
}
|
||||
if (this.hasPendingNonStandardLines(pendingNonStandardLineLimit)) {
|
||||
this.tryFixSearchReplaceBlock(pendingNonStandardLineLimit)
|
||||
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
|
||||
}
|
||||
this.activateSearchState()
|
||||
} else if (isSearchBlockEnd(line)) {
|
||||
// 校验非标内容
|
||||
if (!this.isSearchingActive()) {
|
||||
this.tryFixSearchBlock(pendingNonStandardLineLimit)
|
||||
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
|
||||
}
|
||||
this.activateReplaceState()
|
||||
this.beforeReplace()
|
||||
} else if (isReplaceBlockEnd(line)) {
|
||||
if (!this.isReplacingActive()) {
|
||||
this.tryFixReplaceBlock(pendingNonStandardLineLimit)
|
||||
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
|
||||
}
|
||||
this.lastProcessedIndex = this.searchEndIndex
|
||||
this.resetForNextBlock()
|
||||
} else {
|
||||
// Accumulate content for search or replace
|
||||
// (currentReplaceContent is not being used for anything right now since we directly append to result.)
|
||||
// (We artificially add a linebreak since we split on \n at the beginning. In order to not include a trailing linebreak in the final search/result blocks we need to remove it before using them. This allows for partial line matches to be correctly identified.)
|
||||
// NOTE: search/replace blocks must be arranged in the order they appear in the file due to how we build the content using lastProcessedIndex. We also cannot strip the trailing newline since for non-partial lines it would remove the linebreak from the original content. (If we remove end linebreak from search, then we'd also have to remove it from replace but we can't know if it's a partial line or not since the model may be using the line break to indicate the end of the block rather than as part of the search content.) We require the model to output full lines in order for our fallbacks to work as well.
|
||||
if (this.isReplacingActive()) {
|
||||
this.currentReplaceContent += line + "\n"
|
||||
// Output replacement lines immediately if we know the insertion point
|
||||
if (this.searchMatchIndex !== -1) {
|
||||
this.result += line + "\n"
|
||||
}
|
||||
} else if (this.isSearchingActive()) {
|
||||
this.currentSearchContent += line + "\n"
|
||||
} else {
|
||||
let appendToPendingNonStandardLines = canWritependingNonStandardLines
|
||||
if (appendToPendingNonStandardLines) {
|
||||
// 处理非标内容
|
||||
this.pendingNonStandardLines.push(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
return removeLineCount
|
||||
}
|
||||
|
||||
private beforeReplace() {
|
||||
// Remove trailing linebreak for adding the === marker
|
||||
// if (currentSearchContent.endsWith("\r\n")) {
|
||||
// currentSearchContent = currentSearchContent.slice(0, -2)
|
||||
// } else if (currentSearchContent.endsWith("\n")) {
|
||||
// currentSearchContent = currentSearchContent.slice(0, -1)
|
||||
// }
|
||||
|
||||
if (!this.currentSearchContent) {
|
||||
// Empty search block
|
||||
if (this.originalContent.length === 0) {
|
||||
// New file scenario: nothing to match, just start inserting
|
||||
this.searchMatchIndex = 0
|
||||
this.searchEndIndex = 0
|
||||
} else {
|
||||
// Complete file replacement scenario: treat the entire file as matched
|
||||
this.searchMatchIndex = 0
|
||||
this.searchEndIndex = this.originalContent.length
|
||||
}
|
||||
} else {
|
||||
// Add check for inefficient full-file search
|
||||
// if (currentSearchContent.trim() === originalContent.trim()) {
|
||||
// throw new Error(
|
||||
// "The SEARCH block contains the entire file content. Please either:\n" +
|
||||
// "1. Use an empty SEARCH block to replace the entire file, or\n" +
|
||||
// "2. Make focused changes to specific parts of the file that need modification.",
|
||||
// )
|
||||
// }
|
||||
// Exact search match scenario
|
||||
const exactIndex = this.originalContent.indexOf(this.currentSearchContent, this.lastProcessedIndex)
|
||||
if (exactIndex !== -1) {
|
||||
this.searchMatchIndex = exactIndex
|
||||
this.searchEndIndex = exactIndex + this.currentSearchContent.length
|
||||
} else {
|
||||
// Attempt fallback line-trimmed matching
|
||||
const lineMatch = lineTrimmedFallbackMatch(
|
||||
this.originalContent,
|
||||
this.currentSearchContent,
|
||||
this.lastProcessedIndex,
|
||||
)
|
||||
if (lineMatch) {
|
||||
;[this.searchMatchIndex, this.searchEndIndex] = lineMatch
|
||||
} else {
|
||||
// Try block anchor fallback for larger blocks
|
||||
const blockMatch = blockAnchorFallbackMatch(
|
||||
this.originalContent,
|
||||
this.currentSearchContent,
|
||||
this.lastProcessedIndex,
|
||||
)
|
||||
if (blockMatch) {
|
||||
;[this.searchMatchIndex, this.searchEndIndex] = blockMatch
|
||||
} else {
|
||||
throw new Error(
|
||||
`The SEARCH block:\n${this.currentSearchContent.trimEnd()}\n...does not match anything in the file.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.searchMatchIndex < this.lastProcessedIndex) {
|
||||
throw new Error(
|
||||
`The SEARCH block:\n${this.currentSearchContent.trimEnd()}\n...matched an incorrect content in the file.`,
|
||||
)
|
||||
}
|
||||
// Output everything up to the match location
|
||||
this.result += this.originalContent.slice(this.lastProcessedIndex, this.searchMatchIndex)
|
||||
}
|
||||
|
||||
private tryFixSearchBlock(lineLimit: number): number {
|
||||
let removeLineCount = 0
|
||||
if (lineLimit < 0) {
|
||||
lineLimit = this.pendingNonStandardLines.length
|
||||
}
|
||||
if (!lineLimit) {
|
||||
throw new Error("Invalid SEARCH/REPLACE block structure - no lines available to process")
|
||||
}
|
||||
let searchTagRegexp = /^([-]{3,}|[<]{3,}) SEARCH$/
|
||||
const searchTagIndex = this.findLastMatchingLineIndex(searchTagRegexp, lineLimit)
|
||||
if (searchTagIndex !== -1) {
|
||||
let fixLines = this.pendingNonStandardLines.slice(searchTagIndex, lineLimit)
|
||||
fixLines[0] = SEARCH_BLOCK_START
|
||||
for (const line of fixLines) {
|
||||
removeLineCount += this.internalProcessLine(line, false, searchTagIndex)
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`Invalid REPLACE marker detected - could not find matching SEARCH block starting from line ${searchTagIndex + 1}`,
|
||||
)
|
||||
}
|
||||
return removeLineCount
|
||||
}
|
||||
|
||||
private tryFixReplaceBlock(lineLimit: number): number {
|
||||
let removeLineCount = 0
|
||||
if (lineLimit < 0) {
|
||||
lineLimit = this.pendingNonStandardLines.length
|
||||
}
|
||||
if (!lineLimit) {
|
||||
throw new Error()
|
||||
}
|
||||
let replaceBeginTagRegexp = /^[=]{3,}$/
|
||||
const replaceBeginTagIndex = this.findLastMatchingLineIndex(replaceBeginTagRegexp, lineLimit)
|
||||
if (replaceBeginTagIndex !== -1) {
|
||||
// // 校验非标内容
|
||||
// if (!this.isSearchingActive()) {
|
||||
// removeLineCount += this.tryFixSearchBlock(replaceBeginTagIndex)
|
||||
// }
|
||||
let fixLines = this.pendingNonStandardLines.slice(replaceBeginTagIndex - removeLineCount, lineLimit - removeLineCount)
|
||||
fixLines[0] = SEARCH_BLOCK_END
|
||||
for (const line of fixLines) {
|
||||
removeLineCount += this.internalProcessLine(line, false, replaceBeginTagIndex - removeLineCount)
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Malformed REPLACE block - missing valid separator after line ${replaceBeginTagIndex + 1}`)
|
||||
}
|
||||
return removeLineCount
|
||||
}
|
||||
|
||||
private tryFixSearchReplaceBlock(lineLimit: number): number {
|
||||
let removeLineCount = 0
|
||||
if (lineLimit < 0) {
|
||||
lineLimit = this.pendingNonStandardLines.length
|
||||
}
|
||||
if (!lineLimit) {
|
||||
throw new Error()
|
||||
}
|
||||
|
||||
let replaceEndTagRegexp = /^([+]{3,}|[>]{3,}) REPLACE$/
|
||||
const replaceEndTagIndex = this.findLastMatchingLineIndex(replaceEndTagRegexp, lineLimit)
|
||||
const likeReplaceEndTag = replaceEndTagIndex === lineLimit - 1
|
||||
if (likeReplaceEndTag) {
|
||||
// // 校验非标内容
|
||||
// if (!this.isReplacingActive()) {
|
||||
// removeLineCount += this.tryFixReplaceBlock(replaceEndTagIndex)
|
||||
// }
|
||||
let fixLines = this.pendingNonStandardLines.slice(replaceEndTagIndex - removeLineCount, lineLimit - removeLineCount)
|
||||
fixLines[fixLines.length - 1] = REPLACE_BLOCK_END
|
||||
for (const line of fixLines) {
|
||||
removeLineCount += this.internalProcessLine(line, false, replaceEndTagIndex - removeLineCount)
|
||||
}
|
||||
} else {
|
||||
throw new Error("Malformed SEARCH/REPLACE block structure: Missing valid closing REPLACE marker")
|
||||
}
|
||||
return removeLineCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes trailing empty lines from the pendingNonStandardLines array
|
||||
* @param lineLimit - The index to start checking from (exclusive).
|
||||
* Removes empty lines from lineLimit-1 backwards.
|
||||
* @returns The number of empty lines removed
|
||||
*/
|
||||
private trimPendingNonStandardTrailingEmptyLines(lineLimit: number): number {
|
||||
let removedCount = 0
|
||||
let i = Math.min(lineLimit, this.pendingNonStandardLines.length) - 1
|
||||
|
||||
while (i >= 0 && this.pendingNonStandardLines[i].trim() === "") {
|
||||
this.pendingNonStandardLines.pop()
|
||||
removedCount++
|
||||
i--
|
||||
}
|
||||
|
||||
return removedCount
|
||||
}
|
||||
}
|
||||
|
||||
export async function constructNewFileContentV2(diffContent: string, originalContent: string, isFinal: boolean): Promise<string> {
|
||||
let newFileContentConstructor = new NewFileContentConstructor(originalContent, isFinal)
|
||||
|
||||
let lines = diffContent.split("\n")
|
||||
|
||||
// If the last line looks like a partial marker but isn't recognized,
|
||||
// remove it because it might be incomplete.
|
||||
const lastLine = lines[lines.length - 1]
|
||||
if (
|
||||
lines.length > 0 &&
|
||||
(lastLine.startsWith(SEARCH_BLOCK_CHAR) ||
|
||||
lastLine.startsWith(LEGACY_SEARCH_BLOCK_CHAR) ||
|
||||
lastLine.startsWith("=") ||
|
||||
lastLine.startsWith(REPLACE_BLOCK_CHAR) ||
|
||||
lastLine.startsWith(LEGACY_REPLACE_BLOCK_CHAR)) &&
|
||||
lastLine !== SEARCH_BLOCK_START &&
|
||||
lastLine !== SEARCH_BLOCK_END &&
|
||||
lastLine !== REPLACE_BLOCK_END
|
||||
) {
|
||||
lines.pop()
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
newFileContentConstructor.processLine(line)
|
||||
}
|
||||
|
||||
let result = newFileContentConstructor.getResult()
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,960 @@
|
||||
const SEARCH_BLOCK_START = "------- SEARCH"
|
||||
const SEARCH_BLOCK_END = "======="
|
||||
const REPLACE_BLOCK_END = "+++++++ REPLACE"
|
||||
|
||||
const SEARCH_BLOCK_CHAR = "-"
|
||||
const REPLACE_BLOCK_CHAR = "+"
|
||||
const LEGACY_SEARCH_BLOCK_CHAR = "<"
|
||||
const LEGACY_REPLACE_BLOCK_CHAR = ">"
|
||||
|
||||
// Replace the exact string constants with flexible regex patterns
|
||||
const SEARCH_BLOCK_START_REGEX = /^[-]{3,} SEARCH>?$/
|
||||
const LEGACY_SEARCH_BLOCK_START_REGEX = /^[<]{3,} SEARCH>?$/
|
||||
|
||||
const SEARCH_BLOCK_END_REGEX = /^[=]{3,}$/
|
||||
|
||||
const REPLACE_BLOCK_END_REGEX = /^[+]{3,} REPLACE>?$/
|
||||
const LEGACY_REPLACE_BLOCK_END_REGEX = /^[>]{3,} REPLACE>?$/
|
||||
|
||||
// Similarity thresholds for block anchor fallback matching
|
||||
const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0
|
||||
const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.0
|
||||
|
||||
/**
|
||||
* Levenshtein distance algorithm implementation
|
||||
*/
|
||||
function levenshtein(a: string, b: string): number {
|
||||
// Handle empty strings
|
||||
if (a === "" || b === "") {
|
||||
return Math.max(a.length, b.length)
|
||||
}
|
||||
const matrix = Array.from({ length: a.length + 1 }, (_, i) =>
|
||||
Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
|
||||
)
|
||||
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
||||
matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost)
|
||||
}
|
||||
}
|
||||
return matrix[a.length][b.length]
|
||||
}
|
||||
|
||||
// Helper functions to check if a line matches the flexible patterns
|
||||
function isSearchBlockStart(line: string): boolean {
|
||||
return SEARCH_BLOCK_START_REGEX.test(line) || LEGACY_SEARCH_BLOCK_START_REGEX.test(line)
|
||||
}
|
||||
|
||||
function isSearchBlockEnd(line: string): boolean {
|
||||
return SEARCH_BLOCK_END_REGEX.test(line)
|
||||
}
|
||||
|
||||
function isReplaceBlockEnd(line: string): boolean {
|
||||
return REPLACE_BLOCK_END_REGEX.test(line) || LEGACY_REPLACE_BLOCK_END_REGEX.test(line)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts a line-trimmed fallback match for the given search content in the original content.
|
||||
* It tries to match `searchContent` lines against a block of lines in `originalContent` starting
|
||||
* from `lastProcessedIndex`. Lines are matched by trimming leading/trailing whitespace and ensuring
|
||||
* they are identical afterwards.
|
||||
*
|
||||
* Returns [matchIndexStart, matchIndexEnd] if found, or false if not found.
|
||||
*/
|
||||
function lineTrimmedFallbackMatch(originalContent: string, searchContent: string, startIndex: number): [number, number] | false {
|
||||
// Split both contents into lines
|
||||
const originalLines = originalContent.split("\n")
|
||||
const searchLines = searchContent.split("\n")
|
||||
|
||||
// Trim trailing empty line if exists (from the trailing \n in searchContent)
|
||||
if (searchLines[searchLines.length - 1] === "") {
|
||||
searchLines.pop()
|
||||
}
|
||||
|
||||
// Find the line number where startIndex falls
|
||||
let startLineNum = 0
|
||||
let currentIndex = 0
|
||||
while (currentIndex < startIndex && startLineNum < originalLines.length) {
|
||||
currentIndex += originalLines[startLineNum].length + 1 // +1 for \n
|
||||
startLineNum++
|
||||
}
|
||||
|
||||
// For each possible starting position in original content
|
||||
for (let i = startLineNum; i <= originalLines.length - searchLines.length; i++) {
|
||||
let matches = true
|
||||
|
||||
// Try to match all search lines from this position
|
||||
for (let j = 0; j < searchLines.length; j++) {
|
||||
const originalTrimmed = originalLines[i + j].trim()
|
||||
const searchTrimmed = searchLines[j].trim()
|
||||
|
||||
if (originalTrimmed !== searchTrimmed) {
|
||||
matches = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a match, calculate the exact character positions
|
||||
if (matches) {
|
||||
// Find start character index
|
||||
let matchStartIndex = 0
|
||||
for (let k = 0; k < i; k++) {
|
||||
matchStartIndex += originalLines[k].length + 1 // +1 for \n
|
||||
}
|
||||
|
||||
// Find end character index
|
||||
let matchEndIndex = matchStartIndex
|
||||
for (let k = 0; k < searchLines.length; k++) {
|
||||
matchEndIndex += originalLines[i + k].length + 1 // +1 for \n
|
||||
}
|
||||
|
||||
return [matchStartIndex, matchEndIndex]
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to match blocks of code by using the first and last lines as anchors,
|
||||
* with similarity checking to prevent false positives.
|
||||
* This is a third-tier fallback strategy that helps match blocks where we can identify
|
||||
* the correct location by matching the beginning and end, even if the exact content
|
||||
* differs slightly.
|
||||
*
|
||||
* The matching strategy:
|
||||
* 1. Only attempts to match blocks of 3 or more lines to avoid false positives
|
||||
* 2. Extracts from the search content:
|
||||
* - First line as the "start anchor"
|
||||
* - Last line as the "end anchor"
|
||||
* 3. Collects all candidate positions where both anchors match
|
||||
* 4. Uses levenshtein distance to calculate similarity of middle lines
|
||||
* 5. Returns match only if similarity meets threshold requirements
|
||||
*
|
||||
* This approach is particularly useful for matching blocks of code where:
|
||||
* - The exact content might have minor differences
|
||||
* - The beginning and end of the block are distinctive enough to serve as anchors
|
||||
* - The overall structure (number of lines) remains the same
|
||||
* - The middle content is reasonably similar (prevents false positives)
|
||||
*
|
||||
* @param originalContent - The full content of the original file
|
||||
* @param searchContent - The content we're trying to find in the original file
|
||||
* @param startIndex - The character index in originalContent where to start searching
|
||||
* @returns A tuple of [startIndex, endIndex] if a match is found, false otherwise
|
||||
*/
|
||||
function blockAnchorFallbackMatch(originalContent: string, searchContent: string, startIndex: number): [number, number, number] | false {
|
||||
const originalLines = originalContent.split("\n")
|
||||
const searchLines = searchContent.split("\n")
|
||||
|
||||
// Only use this approach for blocks of 3+ lines
|
||||
if (searchLines.length < 3) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Trim trailing empty line if exists
|
||||
if (searchLines[searchLines.length - 1] === "") {
|
||||
searchLines.pop()
|
||||
}
|
||||
|
||||
const firstLineSearch = searchLines[0].trim()
|
||||
const lastLineSearch = searchLines[searchLines.length - 1].trim()
|
||||
const searchBlockSize = searchLines.length
|
||||
|
||||
// Find the line number where startIndex falls
|
||||
let startLineNum = 0
|
||||
let currentIndex = 0
|
||||
while (currentIndex < startIndex && startLineNum < originalLines.length) {
|
||||
currentIndex += originalLines[startLineNum].length + 1
|
||||
startLineNum++
|
||||
}
|
||||
|
||||
// Collect all candidate positions
|
||||
const candidates: number[] = []
|
||||
for (let i = startLineNum; i <= originalLines.length - searchBlockSize; i++) {
|
||||
if (originalLines[i].trim() === firstLineSearch && originalLines[i + searchBlockSize - 1].trim() === lastLineSearch) {
|
||||
candidates.push(i)
|
||||
}
|
||||
}
|
||||
|
||||
// Return immediately if no candidates
|
||||
if (candidates.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Handle single candidate scenario (using relaxed threshold)
|
||||
if (candidates.length === 1) {
|
||||
const i = candidates[0]
|
||||
let similarity = 0
|
||||
let linesToCheck = searchBlockSize - 2
|
||||
|
||||
for (let j = 1; j < searchBlockSize - 1; j++) {
|
||||
const originalLine = originalLines[i + j].trim()
|
||||
const searchLine = searchLines[j].trim()
|
||||
const maxLen = Math.max(originalLine.length, searchLine.length)
|
||||
if (maxLen === 0) {
|
||||
continue
|
||||
}
|
||||
const distance = levenshtein(originalLine, searchLine)
|
||||
similarity += (1 - distance / maxLen) / linesToCheck
|
||||
|
||||
// Exit early when threshold is reached
|
||||
if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
|
||||
let matchStartIndex = 0
|
||||
for (let k = 0; k < i; k++) {
|
||||
matchStartIndex += originalLines[k].length + 1
|
||||
}
|
||||
let matchEndIndex = matchStartIndex
|
||||
for (let k = 0; k < searchBlockSize; k++) {
|
||||
matchEndIndex += originalLines[i + k].length + 1
|
||||
}
|
||||
return [matchStartIndex, matchEndIndex, similarity]
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Calculate similarity for multiple candidates
|
||||
let bestMatchIndex = -1
|
||||
let maxSimilarity = -1
|
||||
|
||||
for (const i of candidates) {
|
||||
let similarity = 0
|
||||
for (let j = 1; j < searchBlockSize - 1; j++) {
|
||||
const originalLine = originalLines[i + j].trim()
|
||||
const searchLine = searchLines[j].trim()
|
||||
const maxLen = Math.max(originalLine.length, searchLine.length)
|
||||
if (maxLen === 0) {
|
||||
continue
|
||||
}
|
||||
const distance = levenshtein(originalLine, searchLine)
|
||||
similarity += 1 - distance / maxLen
|
||||
}
|
||||
similarity /= searchBlockSize - 2 // Average similarity
|
||||
|
||||
if (similarity > maxSimilarity) {
|
||||
maxSimilarity = similarity
|
||||
bestMatchIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
// Threshold judgment
|
||||
if (maxSimilarity >= MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD) {
|
||||
const i = bestMatchIndex
|
||||
let matchStartIndex = 0
|
||||
for (let k = 0; k < i; k++) {
|
||||
matchStartIndex += originalLines[k].length + 1
|
||||
}
|
||||
let matchEndIndex = matchStartIndex
|
||||
for (let k = 0; k < searchBlockSize; k++) {
|
||||
matchEndIndex += originalLines[i + k].length + 1
|
||||
}
|
||||
return [matchStartIndex, matchEndIndex, maxSimilarity]
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* This function reconstructs the file content by applying a streamed diff (in a
|
||||
* specialized SEARCH/REPLACE block format) to the original file content. It is designed
|
||||
* to handle both incremental updates and the final resulting file after all chunks have
|
||||
* been processed.
|
||||
*
|
||||
* The diff format is a custom structure that uses three markers to define changes:
|
||||
*
|
||||
* ------- SEARCH
|
||||
* [Exact content to find in the original file]
|
||||
* =======
|
||||
* [Content to replace with]
|
||||
* +++++++ REPLACE
|
||||
*
|
||||
* Behavior and Assumptions:
|
||||
* 1. The file is processed chunk-by-chunk. Each chunk of `diffContent` may contain
|
||||
* partial or complete SEARCH/REPLACE blocks. By calling this function with each
|
||||
* incremental chunk (with `isFinal` indicating the last chunk), the final reconstructed
|
||||
* file content is produced.
|
||||
*
|
||||
* 2. Matching Strategy (in order of attempt):
|
||||
* a. Exact Match: First attempts to find the exact SEARCH block text in the original file
|
||||
* b. Line-Trimmed Match: Falls back to line-by-line comparison ignoring leading/trailing whitespace
|
||||
* c. Block Anchor Match: For blocks of 3+ lines, tries to match using first/last lines as anchors
|
||||
* If all matching strategies fail, an error is thrown.
|
||||
*
|
||||
* 3. Empty SEARCH Section:
|
||||
* - If SEARCH is empty and the original file is empty, this indicates creating a new file
|
||||
* (pure insertion).
|
||||
* - If SEARCH is empty and the original file is not empty, this indicates a complete
|
||||
* file replacement (the entire original content is considered matched and replaced).
|
||||
*
|
||||
* 4. Applying Changes:
|
||||
* - Before encountering the "=======" marker, lines are accumulated as search content.
|
||||
* - After "=======" and before ">>>>>>> REPLACE", lines are accumulated as replacement content.
|
||||
* - Once the block is complete (">>>>>>> REPLACE"), the matched section in the original
|
||||
* file is replaced with the accumulated replacement lines, and the position in the original
|
||||
* file is advanced.
|
||||
*
|
||||
* 5. Incremental Output:
|
||||
* - As soon as the match location is found and we are in the REPLACE section, each new
|
||||
* replacement line is appended to the result so that partial updates can be viewed
|
||||
* incrementally.
|
||||
*
|
||||
* 6. Partial Markers:
|
||||
* - If the final line of the chunk looks like it might be part of a marker but is not one
|
||||
* of the known markers, it is removed. This prevents incomplete or partial markers
|
||||
* from corrupting the output.
|
||||
*
|
||||
* 7. Finalization:
|
||||
* - Once all chunks have been processed (when `isFinal` is true), any remaining original
|
||||
* content after the last replaced section is appended to the result.
|
||||
* - Trailing newlines are not forcibly added. The code tries to output exactly what is specified.
|
||||
*
|
||||
* Errors:
|
||||
* - If the search block cannot be matched using any of the available matching strategies,
|
||||
* an error is thrown.
|
||||
*/
|
||||
export async function constructNewFileContent(
|
||||
diffContent: string,
|
||||
originalContent: string,
|
||||
isFinal: boolean,
|
||||
version: "v1" | "v2" = "v1",
|
||||
): Promise<any> {
|
||||
const constructor = constructNewFileContentVersionMapping[version]
|
||||
if (!constructor) {
|
||||
throw new Error(`Invalid version '${version}' for file content constructor`)
|
||||
}
|
||||
return constructor(diffContent, originalContent, isFinal)
|
||||
}
|
||||
|
||||
const constructNewFileContentVersionMapping: Record<
|
||||
string,
|
||||
(diffContent: string, originalContent: string, isFinal: boolean) => Promise<any>
|
||||
> = {
|
||||
v1: constructNewFileContentV1,
|
||||
v2: constructNewFileContentV2,
|
||||
} as const
|
||||
|
||||
async function constructNewFileContentV1(diffContent: string, originalContent: string, isFinal: boolean): Promise<{
|
||||
content: string;
|
||||
replacements: Array<{
|
||||
start: number;
|
||||
end: number;
|
||||
content: string;
|
||||
method: string;
|
||||
similarity: number;
|
||||
searchContent: string;
|
||||
matchedText: string;
|
||||
}>;
|
||||
}> {
|
||||
let result = ""
|
||||
let lastProcessedIndex = 0
|
||||
|
||||
let currentSearchContent = ""
|
||||
let currentReplaceContent = ""
|
||||
let inSearch = false
|
||||
let inReplace = false
|
||||
|
||||
let searchMatchIndex = -1
|
||||
let searchEndIndex = -1
|
||||
let matchMethod = ""
|
||||
let similarityScore = -1.0
|
||||
|
||||
// Track all replacements to handle out-of-order edits
|
||||
let replacements: Array<{
|
||||
start: number;
|
||||
end: number;
|
||||
content: string;
|
||||
method: string;
|
||||
similarity: number;
|
||||
searchContent: string;
|
||||
matchedText: string;
|
||||
}> = []
|
||||
let pendingOutOfOrderReplacement = false
|
||||
|
||||
let lines = diffContent.split("\n")
|
||||
|
||||
// If the last line looks like a partial marker but isn't recognized,
|
||||
// remove it because it might be incomplete.
|
||||
const lastLine = lines[lines.length - 1]
|
||||
if (
|
||||
lines.length > 0 &&
|
||||
(lastLine.startsWith(SEARCH_BLOCK_CHAR) ||
|
||||
lastLine.startsWith(LEGACY_SEARCH_BLOCK_CHAR) ||
|
||||
lastLine.startsWith("=") ||
|
||||
lastLine.startsWith(REPLACE_BLOCK_CHAR) ||
|
||||
lastLine.startsWith(LEGACY_REPLACE_BLOCK_CHAR)) &&
|
||||
!isSearchBlockStart(lastLine) &&
|
||||
!isSearchBlockEnd(lastLine) &&
|
||||
!isReplaceBlockEnd(lastLine)
|
||||
) {
|
||||
lines.pop()
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
if (isSearchBlockStart(line)) {
|
||||
inSearch = true
|
||||
currentSearchContent = ""
|
||||
currentReplaceContent = ""
|
||||
continue
|
||||
}
|
||||
|
||||
if (isSearchBlockEnd(line)) {
|
||||
inSearch = false
|
||||
inReplace = true
|
||||
|
||||
// Remove trailing linebreak for adding the === marker
|
||||
// if (currentSearchContent.endsWith("\r\n")) {
|
||||
// currentSearchContent = currentSearchContent.slice(0, -2)
|
||||
// } else if (currentSearchContent.endsWith("\n")) {
|
||||
// currentSearchContent = currentSearchContent.slice(0, -1)
|
||||
// }
|
||||
|
||||
if (!currentSearchContent) {
|
||||
// Empty search block
|
||||
if (originalContent.length === 0) {
|
||||
// New file scenario: nothing to match, just start inserting
|
||||
searchMatchIndex = 0
|
||||
searchEndIndex = 0
|
||||
matchMethod = "empty_new_file"
|
||||
} else {
|
||||
// ERROR: Empty search block with non-empty file indicates malformed SEARCH marker
|
||||
throw new Error(
|
||||
"Empty SEARCH block detected with non-empty file. This usually indicates a malformed SEARCH marker.\n" +
|
||||
"Please ensure your SEARCH marker follows the correct format:\n" +
|
||||
"- Use '------- SEARCH' (7+ dashes + space + SEARCH)\n",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Add check for inefficient full-file search
|
||||
// if (currentSearchContent.trim() === originalContent.trim()) {
|
||||
// throw new Error(
|
||||
// "The SEARCH block contains the entire file content. Please either:\n" +
|
||||
// "1. Use an empty SEARCH block to replace the entire file, or\n" +
|
||||
// "2. Make focused changes to specific parts of the file that need modification.",
|
||||
// )
|
||||
// }
|
||||
|
||||
// Exact search match scenario
|
||||
const exactIndex = originalContent.indexOf(currentSearchContent, lastProcessedIndex)
|
||||
if (exactIndex !== -1) {
|
||||
searchMatchIndex = exactIndex
|
||||
searchEndIndex = exactIndex + currentSearchContent.length
|
||||
matchMethod = "exact_match"
|
||||
} else {
|
||||
// Attempt fallback line-trimmed matching
|
||||
const lineMatch = lineTrimmedFallbackMatch(originalContent, currentSearchContent, lastProcessedIndex)
|
||||
if (lineMatch) {
|
||||
;[searchMatchIndex, searchEndIndex] = lineMatch
|
||||
matchMethod = "line_trimmed_fallback"
|
||||
} else {
|
||||
// Try block anchor fallback for larger blocks
|
||||
const blockMatch = blockAnchorFallbackMatch(originalContent, currentSearchContent, lastProcessedIndex)
|
||||
if (blockMatch) {
|
||||
;[searchMatchIndex, searchEndIndex, similarityScore] = blockMatch
|
||||
matchMethod = "block_anchor_fallback"
|
||||
} else {
|
||||
// Last resort: search the entire file from the beginning
|
||||
const fullFileIndex = originalContent.indexOf(currentSearchContent, 0)
|
||||
if (fullFileIndex !== -1) {
|
||||
// Found in the file - could be out of order
|
||||
searchMatchIndex = fullFileIndex
|
||||
searchEndIndex = fullFileIndex + currentSearchContent.length
|
||||
matchMethod = "full_file_search"
|
||||
if (searchMatchIndex < lastProcessedIndex) {
|
||||
pendingOutOfOrderReplacement = true
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`The SEARCH block:\n${currentSearchContent.trimEnd()}\n...does not match anything in the file.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is an out-of-order replacement
|
||||
if (searchMatchIndex < lastProcessedIndex) {
|
||||
pendingOutOfOrderReplacement = true
|
||||
}
|
||||
|
||||
// For in-order replacements, output everything up to the match location
|
||||
if (!pendingOutOfOrderReplacement) {
|
||||
result += originalContent.slice(lastProcessedIndex, searchMatchIndex)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (isReplaceBlockEnd(line)) {
|
||||
// Finished one replace block
|
||||
|
||||
if (searchMatchIndex === -1) {
|
||||
throw new Error(
|
||||
`The SEARCH block:\n${currentSearchContent.trimEnd()}\n...is malformatted.`,
|
||||
)
|
||||
}
|
||||
|
||||
// Store this replacement
|
||||
replacements.push({
|
||||
start: searchMatchIndex,
|
||||
end: searchEndIndex,
|
||||
content: currentReplaceContent,
|
||||
method: matchMethod,
|
||||
similarity: similarityScore,
|
||||
searchContent: currentSearchContent,
|
||||
matchedText: originalContent.slice(searchMatchIndex, searchEndIndex),
|
||||
})
|
||||
|
||||
// If this was an in-order replacement, advance lastProcessedIndex
|
||||
if (!pendingOutOfOrderReplacement) {
|
||||
lastProcessedIndex = searchEndIndex
|
||||
}
|
||||
|
||||
// Reset for next block
|
||||
inSearch = false
|
||||
inReplace = false
|
||||
currentSearchContent = ""
|
||||
currentReplaceContent = ""
|
||||
searchMatchIndex = -1
|
||||
searchEndIndex = -1
|
||||
similarityScore = -1.0
|
||||
pendingOutOfOrderReplacement = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Accumulate content for search or replace
|
||||
// (currentReplaceContent is not being used for anything right now since we directly append to result.)
|
||||
// (We artificially add a linebreak since we split on \n at the beginning. In order to not include a trailing linebreak in the final search/result blocks we need to remove it before using them. This allows for partial line matches to be correctly identified.)
|
||||
// NOTE: search/replace blocks must be arranged in the order they appear in the file due to how we build the content using lastProcessedIndex. We also cannot strip the trailing newline since for non-partial lines it would remove the linebreak from the original content. (If we remove end linebreak from search, then we'd also have to remove it from replace but we can't know if it's a partial line or not since the model may be using the line break to indicate the end of the block rather than as part of the search content.) We require the model to output full lines in order for our fallbacks to work as well.
|
||||
if (inSearch) {
|
||||
currentSearchContent += line + "\n"
|
||||
} else if (inReplace) {
|
||||
currentReplaceContent += line + "\n"
|
||||
// Only output replacement lines immediately for in-order replacements
|
||||
if (searchMatchIndex !== -1 && !pendingOutOfOrderReplacement) {
|
||||
result += line + "\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If this is the final chunk, we need to apply all replacements and build the final result
|
||||
if (isFinal) {
|
||||
// Handle the case where we're still in replace mode when processing ends
|
||||
// and this is the final chunk - treat it as if we encountered the REPLACE marker
|
||||
if (inReplace && searchMatchIndex !== -1) {
|
||||
// Store this replacement
|
||||
replacements.push({
|
||||
start: searchMatchIndex,
|
||||
end: searchEndIndex,
|
||||
content: currentReplaceContent,
|
||||
method: matchMethod,
|
||||
similarity: similarityScore,
|
||||
searchContent: currentSearchContent,
|
||||
matchedText: originalContent.slice(searchMatchIndex, searchEndIndex),
|
||||
})
|
||||
|
||||
// If this was an in-order replacement, advance lastProcessedIndex
|
||||
if (!pendingOutOfOrderReplacement) {
|
||||
lastProcessedIndex = searchEndIndex
|
||||
}
|
||||
|
||||
// Reset state
|
||||
inSearch = false
|
||||
inReplace = false
|
||||
currentSearchContent = ""
|
||||
currentReplaceContent = ""
|
||||
searchMatchIndex = -1
|
||||
searchEndIndex = -1
|
||||
pendingOutOfOrderReplacement = false
|
||||
}
|
||||
// end of handling missing replace marker
|
||||
|
||||
// Sort replacements by start position
|
||||
replacements.sort((a, b) => a.start - b.start)
|
||||
|
||||
// Rebuild the entire result by applying all replacements
|
||||
result = ""
|
||||
let currentPos = 0
|
||||
|
||||
for (const replacement of replacements) {
|
||||
// Add original content up to this replacement
|
||||
result += originalContent.slice(currentPos, replacement.start)
|
||||
// Add the replacement content
|
||||
result += replacement.content
|
||||
// Move position to after the replaced section
|
||||
currentPos = replacement.end
|
||||
}
|
||||
|
||||
// Add any remaining original content
|
||||
result += originalContent.slice(currentPos)
|
||||
}
|
||||
|
||||
// For testing - return debug info
|
||||
return {
|
||||
content: result,
|
||||
replacements: replacements
|
||||
}
|
||||
}
|
||||
|
||||
enum ProcessingState {
|
||||
Idle = 0,
|
||||
StateSearch = 1 << 0,
|
||||
StateReplace = 1 << 1,
|
||||
}
|
||||
|
||||
class NewFileContentConstructor {
|
||||
private originalContent: string
|
||||
private isFinal: boolean
|
||||
private state: number
|
||||
private pendingNonStandardLines: string[]
|
||||
private result: string
|
||||
private lastProcessedIndex: number
|
||||
private currentSearchContent: string
|
||||
private currentReplaceContent: string
|
||||
private searchMatchIndex: number
|
||||
private searchEndIndex: number
|
||||
|
||||
constructor(originalContent: string, isFinal: boolean) {
|
||||
this.originalContent = originalContent
|
||||
this.isFinal = isFinal
|
||||
this.pendingNonStandardLines = []
|
||||
this.result = ""
|
||||
this.lastProcessedIndex = 0
|
||||
this.state = ProcessingState.Idle
|
||||
this.currentSearchContent = ""
|
||||
this.currentReplaceContent = ""
|
||||
this.searchMatchIndex = -1
|
||||
this.searchEndIndex = -1
|
||||
}
|
||||
|
||||
private resetForNextBlock() {
|
||||
// Reset for next block
|
||||
this.state = ProcessingState.Idle
|
||||
this.currentSearchContent = ""
|
||||
this.currentReplaceContent = ""
|
||||
this.searchMatchIndex = -1
|
||||
this.searchEndIndex = -1
|
||||
}
|
||||
|
||||
private findLastMatchingLineIndex(regx: RegExp, lineLimit: number) {
|
||||
for (let i = lineLimit; i > 0; ) {
|
||||
i--
|
||||
if (this.pendingNonStandardLines[i].match(regx)) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
private updateProcessingState(newState: ProcessingState) {
|
||||
const isValidTransition =
|
||||
(this.state === ProcessingState.Idle && newState === ProcessingState.StateSearch) ||
|
||||
(this.state === ProcessingState.StateSearch && newState === ProcessingState.StateReplace)
|
||||
|
||||
if (!isValidTransition) {
|
||||
throw new Error(
|
||||
`Invalid state transition.\n` +
|
||||
"Valid transitions are:\n" +
|
||||
"- Idle → StateSearch\n" +
|
||||
"- StateSearch → StateReplace",
|
||||
)
|
||||
}
|
||||
|
||||
this.state |= newState
|
||||
}
|
||||
|
||||
private isStateActive(state: ProcessingState): boolean {
|
||||
return (this.state & state) === state
|
||||
}
|
||||
|
||||
private activateReplaceState() {
|
||||
this.updateProcessingState(ProcessingState.StateReplace)
|
||||
}
|
||||
|
||||
private activateSearchState() {
|
||||
this.updateProcessingState(ProcessingState.StateSearch)
|
||||
this.currentSearchContent = ""
|
||||
this.currentReplaceContent = ""
|
||||
}
|
||||
|
||||
private isSearchingActive(): boolean {
|
||||
return this.isStateActive(ProcessingState.StateSearch)
|
||||
}
|
||||
|
||||
private isReplacingActive(): boolean {
|
||||
return this.isStateActive(ProcessingState.StateReplace)
|
||||
}
|
||||
|
||||
private hasPendingNonStandardLines(pendingNonStandardLineLimit: number): boolean {
|
||||
return this.pendingNonStandardLines.length - pendingNonStandardLineLimit < this.pendingNonStandardLines.length
|
||||
}
|
||||
|
||||
public processLine(line: string) {
|
||||
this.internalProcessLine(line, true, this.pendingNonStandardLines.length)
|
||||
}
|
||||
|
||||
public getResult() {
|
||||
// If this is the final chunk, append any remaining original content
|
||||
if (this.isFinal && this.lastProcessedIndex < this.originalContent.length) {
|
||||
this.result += this.originalContent.slice(this.lastProcessedIndex)
|
||||
}
|
||||
if (this.isFinal && this.state !== ProcessingState.Idle) {
|
||||
throw new Error("File processing incomplete - SEARCH/REPLACE operations still active during finalization")
|
||||
}
|
||||
return this.result
|
||||
}
|
||||
|
||||
private internalProcessLine(
|
||||
line: string,
|
||||
canWritependingNonStandardLines: boolean,
|
||||
pendingNonStandardLineLimit: number,
|
||||
): number {
|
||||
let removeLineCount = 0
|
||||
if (isSearchBlockStart(line)) {
|
||||
removeLineCount = this.trimPendingNonStandardTrailingEmptyLines(pendingNonStandardLineLimit)
|
||||
if (removeLineCount > 0) {
|
||||
pendingNonStandardLineLimit = pendingNonStandardLineLimit - removeLineCount
|
||||
}
|
||||
if (this.hasPendingNonStandardLines(pendingNonStandardLineLimit)) {
|
||||
this.tryFixSearchReplaceBlock(pendingNonStandardLineLimit)
|
||||
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
|
||||
}
|
||||
this.activateSearchState()
|
||||
} else if (isSearchBlockEnd(line)) {
|
||||
// 校验非标内容
|
||||
if (!this.isSearchingActive()) {
|
||||
this.tryFixSearchBlock(pendingNonStandardLineLimit)
|
||||
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
|
||||
}
|
||||
this.activateReplaceState()
|
||||
this.beforeReplace()
|
||||
} else if (isReplaceBlockEnd(line)) {
|
||||
if (!this.isReplacingActive()) {
|
||||
this.tryFixReplaceBlock(pendingNonStandardLineLimit)
|
||||
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
|
||||
}
|
||||
this.lastProcessedIndex = this.searchEndIndex
|
||||
this.resetForNextBlock()
|
||||
} else {
|
||||
// Accumulate content for search or replace
|
||||
// (currentReplaceContent is not being used for anything right now since we directly append to result.)
|
||||
// (We artificially add a linebreak since we split on \n at the beginning. In order to not include a trailing linebreak in the final search/result blocks we need to remove it before using them. This allows for partial line matches to be correctly identified.)
|
||||
// NOTE: search/replace blocks must be arranged in the order they appear in the file due to how we build the content using lastProcessedIndex. We also cannot strip the trailing newline since for non-partial lines it would remove the linebreak from the original content. (If we remove end linebreak from search, then we'd also have to remove it from replace but we can't know if it's a partial line or not since the model may be using the line break to indicate the end of the block rather than as part of the search content.) We require the model to output full lines in order for our fallbacks to work as well.
|
||||
if (this.isReplacingActive()) {
|
||||
this.currentReplaceContent += line + "\n"
|
||||
// Output replacement lines immediately if we know the insertion point
|
||||
if (this.searchMatchIndex !== -1) {
|
||||
this.result += line + "\n"
|
||||
}
|
||||
} else if (this.isSearchingActive()) {
|
||||
this.currentSearchContent += line + "\n"
|
||||
} else {
|
||||
let appendToPendingNonStandardLines = canWritependingNonStandardLines
|
||||
if (appendToPendingNonStandardLines) {
|
||||
// 处理非标内容
|
||||
this.pendingNonStandardLines.push(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
return removeLineCount
|
||||
}
|
||||
|
||||
private beforeReplace() {
|
||||
// Remove trailing linebreak for adding the === marker
|
||||
// if (currentSearchContent.endsWith("\r\n")) {
|
||||
// currentSearchContent = currentSearchContent.slice(0, -2)
|
||||
// } else if (currentSearchContent.endsWith("\n")) {
|
||||
// currentSearchContent = currentSearchContent.slice(0, -1)
|
||||
// }
|
||||
|
||||
if (!this.currentSearchContent) {
|
||||
// Empty search block
|
||||
if (this.originalContent.length === 0) {
|
||||
// New file scenario: nothing to match, just start inserting
|
||||
this.searchMatchIndex = 0
|
||||
this.searchEndIndex = 0
|
||||
} else {
|
||||
// Complete file replacement scenario: treat the entire file as matched
|
||||
this.searchMatchIndex = 0
|
||||
this.searchEndIndex = this.originalContent.length
|
||||
}
|
||||
} else {
|
||||
// Add check for inefficient full-file search
|
||||
// if (currentSearchContent.trim() === originalContent.trim()) {
|
||||
// throw new Error(
|
||||
// "The SEARCH block contains the entire file content. Please either:\n" +
|
||||
// "1. Use an empty SEARCH block to replace the entire file, or\n" +
|
||||
// "2. Make focused changes to specific parts of the file that need modification.",
|
||||
// )
|
||||
// }
|
||||
// Exact search match scenario
|
||||
const exactIndex = this.originalContent.indexOf(this.currentSearchContent, this.lastProcessedIndex)
|
||||
if (exactIndex !== -1) {
|
||||
this.searchMatchIndex = exactIndex
|
||||
this.searchEndIndex = exactIndex + this.currentSearchContent.length
|
||||
} else {
|
||||
// Attempt fallback line-trimmed matching
|
||||
const lineMatch = lineTrimmedFallbackMatch(
|
||||
this.originalContent,
|
||||
this.currentSearchContent,
|
||||
this.lastProcessedIndex,
|
||||
)
|
||||
if (lineMatch) {
|
||||
;[this.searchMatchIndex, this.searchEndIndex] = lineMatch
|
||||
} else {
|
||||
// Try block anchor fallback for larger blocks
|
||||
const blockMatch = blockAnchorFallbackMatch(
|
||||
this.originalContent,
|
||||
this.currentSearchContent,
|
||||
this.lastProcessedIndex,
|
||||
)
|
||||
if (blockMatch) {
|
||||
;[this.searchMatchIndex, this.searchEndIndex, /* ignore similarity */] = blockMatch
|
||||
} else {
|
||||
throw new Error(
|
||||
`The SEARCH block:\n${this.currentSearchContent.trimEnd()}\n...does not match anything in the file.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.searchMatchIndex < this.lastProcessedIndex) {
|
||||
throw new Error(
|
||||
`The SEARCH block:\n${this.currentSearchContent.trimEnd()}\n...matched an incorrect content in the file.`,
|
||||
)
|
||||
}
|
||||
// Output everything up to the match location
|
||||
this.result += this.originalContent.slice(this.lastProcessedIndex, this.searchMatchIndex)
|
||||
}
|
||||
|
||||
private tryFixSearchBlock(lineLimit: number): number {
|
||||
let removeLineCount = 0
|
||||
if (lineLimit < 0) {
|
||||
lineLimit = this.pendingNonStandardLines.length
|
||||
}
|
||||
if (!lineLimit) {
|
||||
throw new Error("Invalid SEARCH/REPLACE block structure - no lines available to process")
|
||||
}
|
||||
let searchTagRegexp = /^([-]{3,}|[<]{3,}) SEARCH$/
|
||||
const searchTagIndex = this.findLastMatchingLineIndex(searchTagRegexp, lineLimit)
|
||||
if (searchTagIndex !== -1) {
|
||||
let fixLines = this.pendingNonStandardLines.slice(searchTagIndex, lineLimit)
|
||||
fixLines[0] = SEARCH_BLOCK_START
|
||||
for (const line of fixLines) {
|
||||
removeLineCount += this.internalProcessLine(line, false, searchTagIndex)
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`Invalid REPLACE marker detected - could not find matching SEARCH block starting from line ${searchTagIndex + 1}`,
|
||||
)
|
||||
}
|
||||
return removeLineCount
|
||||
}
|
||||
|
||||
private tryFixReplaceBlock(lineLimit: number): number {
|
||||
let removeLineCount = 0
|
||||
if (lineLimit < 0) {
|
||||
lineLimit = this.pendingNonStandardLines.length
|
||||
}
|
||||
if (!lineLimit) {
|
||||
throw new Error()
|
||||
}
|
||||
let replaceBeginTagRegexp = /^[=]{3,}$/
|
||||
const replaceBeginTagIndex = this.findLastMatchingLineIndex(replaceBeginTagRegexp, lineLimit)
|
||||
if (replaceBeginTagIndex !== -1) {
|
||||
// // 校验非标内容
|
||||
// if (!this.isSearchingActive()) {
|
||||
// removeLineCount += this.tryFixSearchBlock(replaceBeginTagIndex)
|
||||
// }
|
||||
let fixLines = this.pendingNonStandardLines.slice(replaceBeginTagIndex - removeLineCount, lineLimit - removeLineCount)
|
||||
fixLines[0] = SEARCH_BLOCK_END
|
||||
for (const line of fixLines) {
|
||||
removeLineCount += this.internalProcessLine(line, false, replaceBeginTagIndex - removeLineCount)
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Malformed REPLACE block - missing valid separator after line ${replaceBeginTagIndex + 1}`)
|
||||
}
|
||||
return removeLineCount
|
||||
}
|
||||
|
||||
private tryFixSearchReplaceBlock(lineLimit: number): number {
|
||||
let removeLineCount = 0
|
||||
if (lineLimit < 0) {
|
||||
lineLimit = this.pendingNonStandardLines.length
|
||||
}
|
||||
if (!lineLimit) {
|
||||
throw new Error()
|
||||
}
|
||||
|
||||
let replaceEndTagRegexp = /^([+]{3,}|[>]{3,}) REPLACE$/
|
||||
const replaceEndTagIndex = this.findLastMatchingLineIndex(replaceEndTagRegexp, lineLimit)
|
||||
const likeReplaceEndTag = replaceEndTagIndex === lineLimit - 1
|
||||
if (likeReplaceEndTag) {
|
||||
// // 校验非标内容
|
||||
// if (!this.isReplacingActive()) {
|
||||
// removeLineCount += this.tryFixReplaceBlock(replaceEndTagIndex)
|
||||
// }
|
||||
let fixLines = this.pendingNonStandardLines.slice(replaceEndTagIndex - removeLineCount, lineLimit - removeLineCount)
|
||||
fixLines[fixLines.length - 1] = REPLACE_BLOCK_END
|
||||
for (const line of fixLines) {
|
||||
removeLineCount += this.internalProcessLine(line, false, replaceEndTagIndex - removeLineCount)
|
||||
}
|
||||
} else {
|
||||
throw new Error("Malformed SEARCH/REPLACE block structure: Missing valid closing REPLACE marker")
|
||||
}
|
||||
return removeLineCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes trailing empty lines from the pendingNonStandardLines array
|
||||
* @param lineLimit - The index to start checking from (exclusive).
|
||||
* Removes empty lines from lineLimit-1 backwards.
|
||||
* @returns The number of empty lines removed
|
||||
*/
|
||||
private trimPendingNonStandardTrailingEmptyLines(lineLimit: number): number {
|
||||
let removedCount = 0
|
||||
let i = Math.min(lineLimit, this.pendingNonStandardLines.length) - 1
|
||||
|
||||
while (i >= 0 && this.pendingNonStandardLines[i].trim() === "") {
|
||||
this.pendingNonStandardLines.pop()
|
||||
removedCount++
|
||||
i--
|
||||
}
|
||||
|
||||
return removedCount
|
||||
}
|
||||
}
|
||||
|
||||
export async function constructNewFileContentV2(diffContent: string, originalContent: string, isFinal: boolean): Promise<string> {
|
||||
let newFileContentConstructor = new NewFileContentConstructor(originalContent, isFinal)
|
||||
|
||||
let lines = diffContent.split("\n")
|
||||
|
||||
// If the last line looks like a partial marker but isn't recognized,
|
||||
// remove it because it might be incomplete.
|
||||
const lastLine = lines[lines.length - 1]
|
||||
if (
|
||||
lines.length > 0 &&
|
||||
(lastLine.startsWith(SEARCH_BLOCK_CHAR) ||
|
||||
lastLine.startsWith(LEGACY_SEARCH_BLOCK_CHAR) ||
|
||||
lastLine.startsWith("=") ||
|
||||
lastLine.startsWith(REPLACE_BLOCK_CHAR) ||
|
||||
lastLine.startsWith(LEGACY_REPLACE_BLOCK_CHAR)) &&
|
||||
lastLine !== SEARCH_BLOCK_START &&
|
||||
lastLine !== SEARCH_BLOCK_END &&
|
||||
lastLine !== REPLACE_BLOCK_END
|
||||
) {
|
||||
lines.pop()
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
newFileContentConstructor.processLine(line)
|
||||
}
|
||||
|
||||
let result = newFileContentConstructor.getResult()
|
||||
return result
|
||||
}
|
||||
@@ -23,3 +23,9 @@ export const formatResponse = {
|
||||
return formatImagesIntoBlocks(images)
|
||||
},
|
||||
}
|
||||
|
||||
export function log(isVerbose: boolean, message: string) {
|
||||
if (isVerbose) {
|
||||
console.log(message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,10 +29,12 @@ export interface TestConfig {
|
||||
model_id: string
|
||||
system_prompt_name: string
|
||||
number_of_runs: number
|
||||
max_attempts_per_case: number
|
||||
parsing_function: string
|
||||
diff_edit_function: string
|
||||
thinking_tokens_budget: number
|
||||
replay: boolean
|
||||
diff_apply_file?: string
|
||||
}
|
||||
|
||||
export interface SystemPromptDetails {
|
||||
@@ -80,6 +82,7 @@ export interface TestResult {
|
||||
diffEdit?: string
|
||||
toolCalls?: ExtractedToolCall[]
|
||||
diffEditSuccess?: boolean
|
||||
replacementData?: any
|
||||
error?: string
|
||||
errorString?: string
|
||||
}
|
||||
@@ -100,4 +103,6 @@ export interface TestInput {
|
||||
diffEditFunction: string
|
||||
thinkingBudgetTokens: number
|
||||
originalDiffEditToolCallMessage?: string
|
||||
diffApplyFile?: string
|
||||
isVerbose: boolean
|
||||
}
|
||||
|
||||
Generated
+374
-67
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-dev",
|
||||
"version": "3.17.15",
|
||||
"version": "3.18.6",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-dev",
|
||||
"version": "3.17.15",
|
||||
"version": "3.18.6",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.37.0",
|
||||
@@ -109,6 +109,7 @@
|
||||
"prettier": "^3.3.3",
|
||||
"protoc-gen-ts": "^0.8.7",
|
||||
"proxyquire": "^2.1.3",
|
||||
"rimraf": "^6.0.1",
|
||||
"should": "^13.2.3",
|
||||
"sinon": "^19.0.2",
|
||||
"ts-node": "^10.9.2",
|
||||
@@ -3701,6 +3702,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/balanced-match": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
|
||||
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/brace-expansion": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
|
||||
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@isaacs/balanced-match": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
@@ -4025,6 +4049,17 @@
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/detect-libc": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
|
||||
@@ -4035,6 +4070,28 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"deprecated": "Glob versions prior to v9 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
@@ -4075,6 +4132,36 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/rimraf": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
"bin": {
|
||||
"rimraf": "bin.js"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@mdx-js/mdx": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.0.tgz",
|
||||
@@ -13169,6 +13256,69 @@
|
||||
"node": "^10.12.0 || >=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/flat-cache/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/flat-cache/node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"deprecated": "Glob versions prior to v9 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/flat-cache/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/flat-cache/node_modules/rimraf": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
"bin": {
|
||||
"rimraf": "bin.js"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz",
|
||||
@@ -13206,12 +13356,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz",
|
||||
"integrity": "sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==",
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.0",
|
||||
"cross-spawn": "^7.0.6",
|
||||
"signal-exit": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -20778,66 +20928,106 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rimraf": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz",
|
||||
"integrity": "sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
"glob": "^11.0.0",
|
||||
"package-json-from-dist": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"rimraf": "bin.js"
|
||||
"rimraf": "dist/esm/bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/rimraf/node_modules/brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/rimraf/node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"deprecated": "Glob versions prior to v9 are no longer supported",
|
||||
"version": "11.0.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz",
|
||||
"integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
"foreground-child": "^3.3.1",
|
||||
"jackspeak": "^4.1.1",
|
||||
"minimatch": "^10.0.3",
|
||||
"minipass": "^7.1.2",
|
||||
"package-json-from-dist": "^1.0.0",
|
||||
"path-scurry": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"glob": "dist/esm/bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/rimraf/node_modules/jackspeak": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz",
|
||||
"integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/cliui": "^8.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/rimraf/node_modules/lru-cache": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz",
|
||||
"integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/rimraf/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "10.0.3",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz",
|
||||
"integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
"@isaacs/brace-expansion": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/rimraf/node_modules/path-scurry": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz",
|
||||
"integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"lru-cache": "^11.0.0",
|
||||
"minipass": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/router": {
|
||||
@@ -26670,6 +26860,21 @@
|
||||
"dev": true,
|
||||
"requires": {}
|
||||
},
|
||||
"@isaacs/balanced-match": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
|
||||
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
|
||||
"dev": true
|
||||
},
|
||||
"@isaacs/brace-expansion": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
|
||||
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@isaacs/balanced-match": "^4.0.1"
|
||||
}
|
||||
},
|
||||
"@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
@@ -26909,12 +27114,36 @@
|
||||
"debug": "4"
|
||||
}
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"detect-libc": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
|
||||
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
|
||||
"dev": true
|
||||
},
|
||||
"glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
@@ -26941,6 +27170,24 @@
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
}
|
||||
},
|
||||
"rimraf": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"glob": "^7.1.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -33440,6 +33687,50 @@
|
||||
"flatted": "^3.2.9",
|
||||
"keyv": "^4.5.3",
|
||||
"rimraf": "^3.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
}
|
||||
},
|
||||
"rimraf": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"glob": "^7.1.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"flatted": {
|
||||
@@ -33463,11 +33754,11 @@
|
||||
}
|
||||
},
|
||||
"foreground-child": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz",
|
||||
"integrity": "sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==",
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||
"requires": {
|
||||
"cross-spawn": "^7.0.0",
|
||||
"cross-spawn": "^7.0.6",
|
||||
"signal-exit": "^4.0.1"
|
||||
}
|
||||
},
|
||||
@@ -38623,45 +38914,61 @@
|
||||
"dev": true
|
||||
},
|
||||
"rimraf": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz",
|
||||
"integrity": "sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"glob": "^7.1.3"
|
||||
"glob": "^11.0.0",
|
||||
"package-json-from-dist": "^1.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"glob": {
|
||||
"version": "11.0.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz",
|
||||
"integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
"foreground-child": "^3.3.1",
|
||||
"jackspeak": "^4.1.1",
|
||||
"minimatch": "^10.0.3",
|
||||
"minipass": "^7.1.2",
|
||||
"package-json-from-dist": "^1.0.0",
|
||||
"path-scurry": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"jackspeak": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz",
|
||||
"integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
"@isaacs/cliui": "^8.0.2"
|
||||
}
|
||||
},
|
||||
"lru-cache": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz",
|
||||
"integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==",
|
||||
"dev": true
|
||||
},
|
||||
"minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "10.0.3",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz",
|
||||
"integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
"@isaacs/brace-expansion": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"path-scurry": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz",
|
||||
"integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"lru-cache": "^11.0.0",
|
||||
"minipass": "^7.1.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -2,7 +2,7 @@
|
||||
"name": "claude-dev",
|
||||
"displayName": "Cline",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "3.17.15",
|
||||
"version": "3.18.6",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
@@ -332,13 +332,14 @@
|
||||
"package": "npm run check-types && npm run build:webview && npm run lint && node esbuild.js --production",
|
||||
"protos": "node proto/build-proto.js && node scripts/generate-server-setup.mjs && node scripts/generate-host-bridge-client.mjs",
|
||||
"postprotos": "prettier src/shared/proto src/core/controller src/hosts/ webview-ui/src/services src/generated --write --log-level warn",
|
||||
"clean": "rimraf dist dist-standalone webview-ui/build src/generated out/",
|
||||
"compile-tests": "node ./scripts/build-tests.js",
|
||||
"watch-tests": "tsc -p . -w --outDir out",
|
||||
"pretest": "npm run compile-tests && npm run compile && npm run compile-standalone && npm run lint",
|
||||
"check-types": "npm run protos && tsc --noEmit",
|
||||
"lint": "eslint src --ext ts && eslint webview-ui/src --ext ts && buf lint && cd webview-ui && npm run lint",
|
||||
"format": "prettier . --check",
|
||||
"format:fix": "prettier . --write",
|
||||
"pretest": "npm run compile && npm run compile-tests && npm run compile-standalone && npm run lint",
|
||||
"test": "npm-run-all test:unit test:integration",
|
||||
"test:ci": "node scripts/test-ci.js",
|
||||
"test:integration": "vscode-test",
|
||||
@@ -396,6 +397,7 @@
|
||||
"prettier": "^3.3.3",
|
||||
"protoc-gen-ts": "^0.8.7",
|
||||
"proxyquire": "^2.1.3",
|
||||
"rimraf": "^6.0.1",
|
||||
"should": "^13.2.3",
|
||||
"sinon": "^19.0.2",
|
||||
"ts-node": "^10.9.2",
|
||||
|
||||
+96
-43
@@ -1,76 +1,129 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
import "common.proto";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
import "common.proto";
|
||||
|
||||
// Service for account-related operations
|
||||
service AccountService {
|
||||
// Handles the user clicking the login link in the UI.
|
||||
// Generates a secure nonce for state validation, stores it in secrets,
|
||||
// and opens the authentication URL in the external browser.
|
||||
rpc accountLoginClicked(EmptyRequest) returns (String);
|
||||
// Handles the user clicking the login link in the UI.
|
||||
// Generates a secure nonce for state validation, stores it in secrets,
|
||||
// and opens the authentication URL in the external browser.
|
||||
rpc accountLoginClicked(EmptyRequest) returns (String);
|
||||
|
||||
// Handles the user clicking the logout button in the UI.
|
||||
// Clears API keys and user state.
|
||||
rpc accountLogoutClicked(EmptyRequest) returns (Empty);
|
||||
// Handles the user clicking the logout button in the UI.
|
||||
// Clears API keys and user state.
|
||||
rpc accountLogoutClicked(EmptyRequest) returns (Empty);
|
||||
|
||||
// Subscribe to auth callback events (when authentication tokens are received)
|
||||
rpc subscribeToAuthCallback(EmptyRequest) returns (stream String);
|
||||
// Subscribe to auth status update events (when authentication state changes)
|
||||
rpc subscribeToAuthStatusUpdate(EmptyRequest)
|
||||
returns (stream AuthState);
|
||||
|
||||
// Handles authentication state changes from the Firebase context.
|
||||
// Updates the user info in global state and returns the updated value.
|
||||
rpc authStateChanged(AuthStateChangedRequest) returns (AuthStateChanged);
|
||||
// Handles authentication state changes from the Firebase context.
|
||||
// Updates the user info in global state and returns the updated value.
|
||||
rpc authStateChanged(AuthStateChangedRequest)
|
||||
returns (AuthState);
|
||||
|
||||
// Fetches all user credits data (balance, usage transactions, payment transactions)
|
||||
rpc fetchUserCreditsData(EmptyRequest) returns (UserCreditsData);
|
||||
// Fetches all user credits data
|
||||
// (balance, usage transactions, payment transactions)
|
||||
rpc getUserCredits(EmptyRequest) returns (UserCreditsData);
|
||||
|
||||
rpc getOrganizationCredits(GetOrganizationCreditsRequest) returns (OrganizationCreditsData);
|
||||
|
||||
// Fetches all user organizations data
|
||||
// Returns a list of UserOrganization objects
|
||||
rpc getUserOrganizations(EmptyRequest) returns (UserOrganizationsResponse);
|
||||
|
||||
rpc setUserOrganization(UserOrganizationUpdateRequest) returns (Empty);
|
||||
}
|
||||
|
||||
message AuthStateChangedRequest {
|
||||
Metadata metadata = 1;
|
||||
UserInfo user = 2;
|
||||
Metadata metadata = 1;
|
||||
UserInfo user = 2;
|
||||
}
|
||||
|
||||
message AuthStateChanged {
|
||||
optional UserInfo user = 1;
|
||||
message AuthState {
|
||||
optional UserInfo user = 1;
|
||||
}
|
||||
|
||||
// User's information
|
||||
message UserInfo {
|
||||
optional string display_name = 1;
|
||||
optional string email = 2;
|
||||
optional string photo_url = 3;
|
||||
string uid = 1;
|
||||
optional string display_name = 2;
|
||||
optional string email = 3;
|
||||
optional string photo_url = 4;
|
||||
}
|
||||
|
||||
message UserOrganization {
|
||||
bool active = 1;
|
||||
string member_id = 2;
|
||||
string name = 3;
|
||||
string organization_id = 4;
|
||||
repeated string roles = 5; // ["admin", "member", "owner"]
|
||||
}
|
||||
|
||||
message UserOrganizationsResponse {
|
||||
repeated UserOrganization organizations = 1;
|
||||
}
|
||||
|
||||
message UserOrganizationUpdateRequest {
|
||||
optional string organization_id = 1;
|
||||
}
|
||||
|
||||
// Response containing all user credits data
|
||||
message UserCreditsData {
|
||||
UserCreditsBalance balance = 1;
|
||||
repeated UsageTransaction usage_transactions = 2;
|
||||
repeated PaymentTransaction payment_transactions = 3;
|
||||
UserCreditsBalance balance = 1;
|
||||
repeated UsageTransaction usage_transactions = 2;
|
||||
repeated PaymentTransaction payment_transactions = 3;
|
||||
}
|
||||
|
||||
message GetOrganizationCreditsRequest {
|
||||
string organization_id = 1;
|
||||
}
|
||||
|
||||
message OrganizationCreditsData {
|
||||
UserCreditsBalance balance = 1;
|
||||
string organization_id = 2;
|
||||
repeated OrganizationUsageTransaction usage_transactions = 3;
|
||||
}
|
||||
|
||||
// User's current credit balance
|
||||
message UserCreditsBalance {
|
||||
double current_balance = 1;
|
||||
double current_balance = 1;
|
||||
}
|
||||
|
||||
// Usage transaction record
|
||||
message UsageTransaction {
|
||||
string spent_at = 1;
|
||||
string creator_id = 2;
|
||||
double credits = 3;
|
||||
string model_provider = 4;
|
||||
string model = 5;
|
||||
int32 prompt_tokens = 6;
|
||||
int32 completion_tokens = 7;
|
||||
int32 total_tokens = 8;
|
||||
string ai_inference_provider_name = 1;
|
||||
string ai_model_name = 2;
|
||||
string ai_model_type_name = 3;
|
||||
int32 completion_tokens = 4;
|
||||
double cost_usd = 5;
|
||||
string created_at = 6;
|
||||
double credits_used = 7;
|
||||
string generation_id = 8;
|
||||
string organization_id = 9;
|
||||
int32 prompt_tokens = 10;
|
||||
int32 total_tokens = 11;
|
||||
string user_id = 12;
|
||||
}
|
||||
|
||||
// Payment transaction record
|
||||
message PaymentTransaction {
|
||||
string paid_at = 1;
|
||||
string creator_id = 2;
|
||||
int32 amount_cents = 3;
|
||||
double credits = 4;
|
||||
string paid_at = 1;
|
||||
string creator_id = 2;
|
||||
int32 amount_cents = 3;
|
||||
double credits = 4;
|
||||
}
|
||||
|
||||
message OrganizationUsageTransaction {
|
||||
string ai_inference_provider_name = 1;
|
||||
string ai_model_name = 2;
|
||||
string ai_model_type_name = 3;
|
||||
int32 completion_tokens = 4;
|
||||
double cost_usd = 5;
|
||||
string created_at = 6;
|
||||
double credits_used = 7;
|
||||
string generation_id = 8;
|
||||
string organization_id = 9;
|
||||
int32 prompt_tokens = 10;
|
||||
int32 total_tokens = 11;
|
||||
string user_id = 12;
|
||||
}
|
||||
+1
-2
@@ -1,11 +1,10 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
import "common.proto";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
import "common.proto";
|
||||
|
||||
service BrowserService {
|
||||
rpc getBrowserConnectionInfo(EmptyRequest) returns (BrowserConnectionInfo);
|
||||
rpc testBrowserConnection(StringRequest) returns (BrowserConnection);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Configuration file for protocol buffer build scripts
|
||||
// Contains service name mappings used by both build-proto.js and build-go-proto.js
|
||||
|
||||
// List of gRPC services
|
||||
// To add a new service, simply add it to this map and run the build scripts
|
||||
// The service handler will be automatically discovered and used by grpc-handler.ts
|
||||
export const serviceNameMap = {
|
||||
account: "cline.AccountService",
|
||||
browser: "cline.BrowserService",
|
||||
checkpoints: "cline.CheckpointsService",
|
||||
file: "cline.FileService",
|
||||
mcp: "cline.McpService",
|
||||
state: "cline.StateService",
|
||||
task: "cline.TaskService",
|
||||
web: "cline.WebService",
|
||||
models: "cline.ModelsService",
|
||||
slash: "cline.SlashService",
|
||||
ui: "cline.UiService",
|
||||
// Add new services here - no other code changes needed!
|
||||
}
|
||||
|
||||
// List of host gRPC services (IDE API bridge)
|
||||
// These services are implemented in the IDE extension and called by the standalone Cline Core
|
||||
export const hostServiceNameMap = {
|
||||
uri: "host.UriService",
|
||||
watch: "host.WatchService",
|
||||
workspace: "host.WorkspaceService",
|
||||
env: "host.EnvService",
|
||||
window: "host.WindowService",
|
||||
// Add new host services here
|
||||
}
|
||||
+18
-44
@@ -9,16 +9,18 @@ import chalk from "chalk"
|
||||
import os from "os"
|
||||
|
||||
import { createRequire } from "module"
|
||||
import { serviceNameMap, hostServiceNameMap } from "./build-proto-config.js"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const PROTOC = path.join(require.resolve("grpc-tools"), "../bin/protoc")
|
||||
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT_DIR = path.resolve(SCRIPT_DIR, "..")
|
||||
|
||||
const TS_OUT_DIR = path.join(ROOT_DIR, "src/shared/proto")
|
||||
const GRPC_JS_OUT_DIR = path.join(ROOT_DIR, "src/generated/grpc-js")
|
||||
const NICE_JS_OUT_DIR = path.join(ROOT_DIR, "src/generated/nice-grpc")
|
||||
const DESCRIPTOR_OUT_DIR = path.join(ROOT_DIR, "dist-standalone/proto")
|
||||
const TS_OUT_DIR = path.join(ROOT_DIR, "src", "shared", "proto")
|
||||
const GRPC_JS_OUT_DIR = path.join(ROOT_DIR, "src", "generated", "grpc-js")
|
||||
const NICE_JS_OUT_DIR = path.join(ROOT_DIR, "src", "generated", "nice-grpc")
|
||||
const DESCRIPTOR_OUT_DIR = path.join(ROOT_DIR, "dist-standalone", "proto")
|
||||
|
||||
const isWindows = process.platform === "win32"
|
||||
const TS_PROTO_PLUGIN = isWindows
|
||||
@@ -34,33 +36,13 @@ const TS_PROTO_OPTIONS = [
|
||||
"useDate=false", // Timestamp fields will not be automatically converted to Date.
|
||||
]
|
||||
|
||||
// List of gRPC services
|
||||
// To add a new service, simply add it to this map and run this script
|
||||
// The service handler will be automatically discovered and used by grpc-handler.ts
|
||||
const serviceNameMap = {
|
||||
account: "cline.AccountService",
|
||||
browser: "cline.BrowserService",
|
||||
checkpoints: "cline.CheckpointsService",
|
||||
file: "cline.FileService",
|
||||
mcp: "cline.McpService",
|
||||
state: "cline.StateService",
|
||||
task: "cline.TaskService",
|
||||
web: "cline.WebService",
|
||||
models: "cline.ModelsService",
|
||||
slash: "cline.SlashService",
|
||||
ui: "cline.UiService",
|
||||
// Add new services here - no other code changes needed!
|
||||
}
|
||||
const serviceDirs = Object.keys(serviceNameMap).map((serviceKey) => path.join(ROOT_DIR, "src/core/controller", serviceKey))
|
||||
// Service directories derived from imported serviceNameMap
|
||||
const serviceDirs = Object.keys(serviceNameMap).map((serviceKey) => path.join(ROOT_DIR, "src", "core", "controller", serviceKey))
|
||||
|
||||
// List of host gRPC services (IDE API bridge)
|
||||
// These services are implemented in the IDE extension and called by the standalone Cline Core
|
||||
const hostServiceNameMap = {
|
||||
uri: "host.UriService",
|
||||
watch: "host.WatchService",
|
||||
// Add new host services here
|
||||
}
|
||||
const hostServiceDirs = Object.keys(hostServiceNameMap).map((serviceKey) => path.join(ROOT_DIR, "src/hosts/vscode", serviceKey))
|
||||
// Host service directories derived from imported hostServiceNameMap
|
||||
const hostServiceDirs = Object.keys(hostServiceNameMap).map((serviceKey) =>
|
||||
path.join(ROOT_DIR, "src", "hosts", "vscode", serviceKey),
|
||||
)
|
||||
|
||||
async function main() {
|
||||
console.log(chalk.bold.blue("Starting Protocol Buffer code generation..."))
|
||||
@@ -176,7 +158,7 @@ export {
|
||||
${serviceExports.join(",\n\t")}
|
||||
}`
|
||||
|
||||
const filePath = path.join(ROOT_DIR, "webview-ui/src/services/grpc-client.ts")
|
||||
const filePath = path.join(ROOT_DIR, "webview-ui", "src", "services", "grpc-client.ts")
|
||||
await writeFileWithMkdirs(filePath, content)
|
||||
log_verbose(chalk.green(`Generated gRPC client at ${filePath}`))
|
||||
}
|
||||
@@ -385,7 +367,7 @@ export interface ServiceHandlerConfig {
|
||||
export const serviceHandlers: Record<string, ServiceHandlerConfig> = {${serviceConfigs.join(",")}
|
||||
};`
|
||||
|
||||
const configPath = path.join(ROOT_DIR, "src/core/controller/grpc-service-config.ts")
|
||||
const configPath = path.join(ROOT_DIR, "src", "core", "controller", "grpc-service-config.ts")
|
||||
await writeFileWithMkdirs(configPath, content)
|
||||
log_verbose(chalk.green(`Generated service configuration at ${configPath}`))
|
||||
}
|
||||
@@ -601,13 +583,12 @@ async function cleanup() {
|
||||
for (const file of existingFiles) {
|
||||
await fs.unlink(path.join(TS_OUT_DIR, file))
|
||||
}
|
||||
await rmdir(path.join(ROOT_DIR, "src/generated"))
|
||||
await rmdir(path.join(ROOT_DIR, "src", "generated"))
|
||||
|
||||
// Clean up generated files that were moved.
|
||||
await fs.rm(path.join(ROOT_DIR, "src/standalone/services/host-grpc-client.ts"), { force: true })
|
||||
await rmdir(path.join(ROOT_DIR, "src/standalone/services"))
|
||||
|
||||
await fs.rm(path.join(ROOT_DIR, "hosts/vscode"), { force: true, recursive: true })
|
||||
await fs.rm(path.join(ROOT_DIR, "src", "standalone", "services", "host-grpc-client.ts"), { force: true })
|
||||
await rmdir(path.join(ROOT_DIR, "src", "standalone", "services"))
|
||||
await fs.rm(path.join(ROOT_DIR, "hosts", "vscode"), { force: true, recursive: true })
|
||||
await rmdir(path.join(ROOT_DIR, "hosts"))
|
||||
|
||||
await fs.rm(path.join(ROOT_DIR, "src/standalone/server-setup.ts"), { force: true })
|
||||
@@ -635,13 +616,6 @@ async function rmdir(path) {
|
||||
}
|
||||
}
|
||||
|
||||
function serviceNameWithoutPackage(fullServiceName) {
|
||||
return fullServiceName.replace(/.*\./, "")
|
||||
}
|
||||
function lowercaseFirstChar(str) {
|
||||
return str.charAt(0).toLowerCase() + str.slice(1)
|
||||
}
|
||||
|
||||
// Check for Apple Silicon compatibility
|
||||
function checkAppleSiliconCompatibility() {
|
||||
// Only run check on macOS
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
import "common.proto";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
import "common.proto";
|
||||
|
||||
service CheckpointsService {
|
||||
rpc checkpointDiff(Int64Request) returns (Empty);
|
||||
rpc checkpointRestore(CheckpointRestoreRequest) returns (Empty);
|
||||
|
||||
+1
-5
@@ -1,11 +1,10 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
import "common.proto";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
import "common.proto";
|
||||
|
||||
// Service for file-related operations
|
||||
service FileService {
|
||||
// Copies text to clipboard
|
||||
@@ -29,9 +28,6 @@ service FileService {
|
||||
// Search git commits in the workspace
|
||||
rpc searchCommits(StringRequest) returns (GitCommits);
|
||||
|
||||
// Select images from the file system and return as data URLs
|
||||
rpc selectImages(EmptyRequest) returns (StringArray);
|
||||
|
||||
// Select images and other files from the file system and returns as data URLs & paths respectively
|
||||
rpc selectFiles(BooleanRequest) returns (StringArrays);
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package host;
|
||||
option java_package = "bot.cline.host.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
import "common.proto";
|
||||
|
||||
// Provides methods for working with the user's environment.
|
||||
service EnvService {
|
||||
// Writes text to the system clipboard.
|
||||
rpc clipboardWriteText(cline.StringRequest) returns (cline.Empty);
|
||||
|
||||
// Reads text from the system clipboard.
|
||||
rpc clipboardReadText(cline.EmptyRequest) returns (cline.String);
|
||||
|
||||
// Opens a URL in the user's default browser or application.
|
||||
rpc openExternal(cline.StringRequest) returns (cline.Empty);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package host;
|
||||
option java_package = "bot.cline.host.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
import "common.proto";
|
||||
|
||||
// UriService provides methods for working with URIs in the IDE
|
||||
service UriService {
|
||||
// Create a new file URI from a file path
|
||||
rpc file(cline.StringRequest) returns (Uri);
|
||||
|
||||
// Join a URI with additional path segments
|
||||
rpc joinPath(JoinPathRequest) returns (Uri);
|
||||
|
||||
// Parse a string URI into a Uri object
|
||||
rpc parse(cline.StringRequest) returns (Uri);
|
||||
}
|
||||
|
||||
// Uri represents a URI in the IDE
|
||||
message Uri {
|
||||
string scheme = 1;
|
||||
string authority = 2;
|
||||
string path = 3;
|
||||
string query = 4;
|
||||
string fragment = 5;
|
||||
string fs_path = 6;
|
||||
}
|
||||
|
||||
// Request for joining path segments to a URI
|
||||
message JoinPathRequest {
|
||||
cline.Metadata metadata = 1;
|
||||
Uri base = 2;
|
||||
repeated string path_segments = 3;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package host;
|
||||
option java_package = "bot.cline.host.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
import "common.proto";
|
||||
|
||||
// Provides methods for working with IDE windows and editors.
|
||||
service WindowService {
|
||||
// Opens a text document in the editor and returns editor information.
|
||||
rpc showTextDocument(ShowTextDocumentRequest) returns (TextEditorInfo);
|
||||
rpc showOpenDialogue(ShowOpenDialogueRequest) returns (SelectedResources);
|
||||
}
|
||||
|
||||
message ShowTextDocumentRequest {
|
||||
cline.Metadata metadata = 1;
|
||||
string path = 2;
|
||||
optional ShowTextDocumentOptions options = 3;
|
||||
}
|
||||
|
||||
// See https://code.visualstudio.com/api/references/vscode-api#TextDocumentShowOptions
|
||||
message ShowTextDocumentOptions {
|
||||
optional bool preview = 1;
|
||||
optional bool preserve_focus = 2;
|
||||
optional int32 view_column = 3;
|
||||
}
|
||||
|
||||
message TextEditorInfo {
|
||||
string document_path = 1;
|
||||
optional int32 view_column = 2;
|
||||
bool is_active = 3;
|
||||
}
|
||||
|
||||
message ShowOpenDialogueRequest {
|
||||
cline.Metadata metadata = 1;
|
||||
optional bool can_select_many = 2;
|
||||
optional string open_label = 3;
|
||||
optional ShowOpenDialogueFilterOption filters = 4;
|
||||
}
|
||||
|
||||
message ShowOpenDialogueFilterOption {
|
||||
repeated string files = 1;
|
||||
}
|
||||
|
||||
message SelectedResources {
|
||||
repeated string paths = 1;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package host;
|
||||
option java_package = "bot.cline.host.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
// Provides methods for working with workspaces/projects.
|
||||
service WorkspaceService {
|
||||
// Returns a list of the top level directories of the workspace.
|
||||
rpc getWorkspacePaths(GetWorkspacePathsRequest) returns (GetWorkspacePathsResponse);
|
||||
}
|
||||
|
||||
message GetWorkspacePathsRequest {
|
||||
// The unique ID for the workspace/project.
|
||||
// This is currently optional in vscode. It is required in other environments where cline is running at
|
||||
// the application level, and the user can open multiple projects.
|
||||
optional string id = 1;
|
||||
}
|
||||
|
||||
message GetWorkspacePathsResponse {
|
||||
// The unique ID for the workspace/project.
|
||||
optional string id = 1;
|
||||
repeated string paths = 2;
|
||||
}
|
||||
+14
-3
@@ -1,16 +1,15 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
import "common.proto";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
import "common.proto";
|
||||
|
||||
service McpService {
|
||||
rpc toggleMcpServer(ToggleMcpServerRequest) returns (McpServers);
|
||||
rpc updateMcpTimeout(UpdateMcpTimeoutRequest) returns (McpServers);
|
||||
rpc addRemoteMcpServer(AddRemoteMcpServerRequest) returns (McpServers);
|
||||
rpc downloadMcp(StringRequest) returns (Empty);
|
||||
rpc downloadMcp(StringRequest) returns (McpDownloadResponse);
|
||||
rpc restartMcpServer(StringRequest) returns (McpServers);
|
||||
rpc deleteMcpServer(StringRequest) returns (McpServers);
|
||||
rpc toggleToolAutoApprove(ToggleToolAutoApproveRequest) returns (McpServers);
|
||||
@@ -119,3 +118,15 @@ message McpMarketplaceItem {
|
||||
message McpMarketplaceCatalog {
|
||||
repeated McpMarketplaceItem items = 1;
|
||||
}
|
||||
|
||||
message McpDownloadResponse {
|
||||
string mcp_id = 1;
|
||||
string github_url = 2;
|
||||
string name = 3;
|
||||
string author = 4;
|
||||
string description = 5;
|
||||
string readme_content = 6;
|
||||
string llms_installation_content = 7;
|
||||
bool requires_api_key = 8;
|
||||
optional string error = 9;
|
||||
}
|
||||
|
||||
+2
-3
@@ -1,11 +1,10 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
import "common.proto";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
import "common.proto";
|
||||
|
||||
// Service for model-related operations
|
||||
service ModelsService {
|
||||
// Fetches available models from Ollama
|
||||
@@ -165,7 +164,7 @@ message ModelsApiConfiguration {
|
||||
// From ApiHandlerOptions (excluding onRetryAttempt function)
|
||||
optional string api_model_id = 1;
|
||||
optional string api_key = 2;
|
||||
optional string cline_api_key = 3;
|
||||
optional string cline_account_id = 3;
|
||||
optional string task_id = 4;
|
||||
optional string lite_llm_base_url = 5;
|
||||
optional string lite_llm_model_id = 6;
|
||||
|
||||
+1
-2
@@ -1,11 +1,10 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
import "common.proto";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
import "common.proto";
|
||||
|
||||
// SlashService provides methods for managing slash
|
||||
service SlashService {
|
||||
// Sends button click message
|
||||
|
||||
+15
-3
@@ -1,10 +1,9 @@
|
||||
syntax = "proto3";
|
||||
package cline;
|
||||
import "common.proto";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
import "common.proto";
|
||||
|
||||
service StateService {
|
||||
rpc getLatestState(EmptyRequest) returns (State);
|
||||
rpc updateTerminalConnectionTimeout(Int64Request) returns (Int64);
|
||||
@@ -17,6 +16,8 @@ service StateService {
|
||||
rpc togglePlanActMode(TogglePlanActModeRequest) returns (Boolean);
|
||||
rpc updateAutoApprovalSettings(AutoApprovalSettingsRequest) returns (Empty);
|
||||
rpc updateSettings(UpdateSettingsRequest) returns (Empty);
|
||||
rpc updateTelemetrySetting(TelemetrySettingRequest) returns (Empty);
|
||||
rpc setWelcomeViewCompleted(BooleanRequest) returns (Empty);
|
||||
}
|
||||
|
||||
message State {
|
||||
@@ -88,6 +89,17 @@ message AutoApprovalSettingsRequest {
|
||||
repeated string favorites = 7;
|
||||
}
|
||||
|
||||
enum TelemetrySettingEnum {
|
||||
UNSET = 0;
|
||||
ENABLED = 1;
|
||||
DISABLED = 2;
|
||||
}
|
||||
|
||||
message TelemetrySettingRequest {
|
||||
Metadata metadata = 1;
|
||||
TelemetrySettingEnum setting = 2;
|
||||
}
|
||||
|
||||
// Message for updating settings
|
||||
message UpdateSettingsRequest {
|
||||
Metadata metadata = 1;
|
||||
@@ -113,7 +125,7 @@ message ApiConfiguration {
|
||||
optional string api_base_url = 4;
|
||||
|
||||
// Provider-specific API keys
|
||||
optional string cline_api_key = 5;
|
||||
optional string cline_account_id = 5;
|
||||
optional string openrouter_api_key = 6;
|
||||
optional string anthropic_base_url = 7;
|
||||
optional string openai_api_key = 8;
|
||||
|
||||
+8
-10
@@ -1,11 +1,10 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
import "common.proto";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
import "common.proto";
|
||||
|
||||
service TaskService {
|
||||
// Cancels the currently running task
|
||||
rpc cancelTask(EmptyRequest) returns (Empty);
|
||||
@@ -23,8 +22,6 @@ service TaskService {
|
||||
rpc exportTaskWithId(StringRequest) returns (Empty);
|
||||
// Toggles the favorite status of a task
|
||||
rpc toggleTaskFavorite(TaskFavoriteRequest) returns (Empty);
|
||||
// Deletes all non-favorited tasks
|
||||
rpc deleteNonFavoritedTasks(EmptyRequest) returns (DeleteNonFavoritedTasksResults);
|
||||
// Gets filtered task history
|
||||
rpc getTaskHistory(GetTaskHistoryRequest) returns (TaskHistoryArray);
|
||||
// Sends a response to a previous ask operation
|
||||
@@ -35,6 +32,8 @@ service TaskService {
|
||||
rpc taskCompletionViewChanges(Int64Request) returns (Empty);
|
||||
// Executes a quick win task with command and title
|
||||
rpc executeQuickWin(ExecuteQuickWinRequest) returns (Empty);
|
||||
// Deletes all task history
|
||||
rpc deleteAllTaskHistory(EmptyRequest) returns (DeleteAllTaskHistoryCount);
|
||||
}
|
||||
|
||||
// Request message for creating a new task
|
||||
@@ -66,12 +65,6 @@ message TaskResponse {
|
||||
int32 cache_reads = 10;
|
||||
}
|
||||
|
||||
// Results returned when deleting non-favorited tasks
|
||||
message DeleteNonFavoritedTasksResults {
|
||||
int32 tasks_preserved = 1;
|
||||
int32 tasks_deleted = 2;
|
||||
}
|
||||
|
||||
// Request for getting task history with filtering
|
||||
message GetTaskHistoryRequest {
|
||||
Metadata metadata = 1;
|
||||
@@ -116,3 +109,8 @@ message ExecuteQuickWinRequest {
|
||||
string command = 2;
|
||||
string title = 3;
|
||||
}
|
||||
|
||||
// Results returned when deleting all task history
|
||||
message DeleteAllTaskHistoryCount {
|
||||
int32 tasks_deleted = 1;
|
||||
}
|
||||
|
||||
+4
-2
@@ -1,11 +1,10 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
import "common.proto";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
import "common.proto";
|
||||
|
||||
// Enum for webview provider types
|
||||
enum WebviewProviderType {
|
||||
SIDEBAR = 0;
|
||||
@@ -265,4 +264,7 @@ service UiService {
|
||||
|
||||
// Returns the HTML for the webview index page. This is only used by external clients, not by the vscode webview.
|
||||
rpc getWebviewHtml(EmptyRequest) returns (String);
|
||||
|
||||
// Opens a URL in the default browser
|
||||
rpc openUrl(StringRequest) returns (Empty);
|
||||
}
|
||||
|
||||
+1
-2
@@ -1,11 +1,10 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
import "common.proto";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
import "common.proto";
|
||||
|
||||
service WebService {
|
||||
rpc checkIsImageUrl(StringRequest) returns (IsImageUrl);
|
||||
rpc fetchOpenGraphData(StringRequest) returns (OpenGraphData);
|
||||
|
||||
@@ -13,7 +13,8 @@ grep -Ev '//.*vscode' | # remove commented out code
|
||||
sed 's|.*vscode\.|vscode.|'| # remove everything before vscode.
|
||||
sed 's/[^a-zA-Z0-9_.].*$//' | # remove everything after last identifier
|
||||
grep -E '\.[a-z][^.]+$' | # remove types (last part of identifier should be lowercase)
|
||||
sort | uniq > $SDK_DEST
|
||||
sort | uniq -c | sort -n | # Count occurrences
|
||||
cat > $SDK_DEST
|
||||
}
|
||||
echo Wrote uses of the vscode SDK to $(realpath $SDK_DEST)
|
||||
|
||||
|
||||
@@ -1,83 +1,100 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { glob } from "glob"
|
||||
import archiver from "archiver"
|
||||
import { cp } from "fs/promises"
|
||||
import { execSync } from "child_process"
|
||||
|
||||
import fs from "fs"
|
||||
import { cp } from "fs/promises"
|
||||
import { glob } from "glob"
|
||||
import ignore from "ignore"
|
||||
import path from "path"
|
||||
const BUILD_DIR = "dist-standalone"
|
||||
const SOURCE_DIR = "standalone/runtime-files"
|
||||
const RUNTIME_DEPS_DIR = "standalone/runtime-files"
|
||||
|
||||
await cp(SOURCE_DIR, BUILD_DIR, { recursive: true })
|
||||
|
||||
// Run npm install in the distribution directory
|
||||
console.log("Running npm install in distribution directory...")
|
||||
const cwd = process.cwd()
|
||||
process.chdir(BUILD_DIR)
|
||||
try {
|
||||
execSync("npm install", { stdio: "inherit" })
|
||||
// Move the vscode directory into node_modules.
|
||||
// It can't be installed using npm because it will create a symlink which is not portable.
|
||||
fs.renameSync("vscode", path.join("node_modules", "vscode"))
|
||||
} catch (error) {
|
||||
console.error("Error during setup:", error)
|
||||
process.exit(1)
|
||||
} finally {
|
||||
process.chdir(cwd)
|
||||
async function main() {
|
||||
await installNodeDependencies()
|
||||
await zipDistribution()
|
||||
}
|
||||
|
||||
// Check for native .node modules.
|
||||
const nativeModules = await glob("**/*.node", { cwd: BUILD_DIR, nodir: true })
|
||||
if (nativeModules.length > 0) {
|
||||
console.error("Native node modules cannot be included in the standalone distribution:\n", nativeModules.join("\n"))
|
||||
process.exit(1)
|
||||
async function installNodeDependencies() {
|
||||
await cpr(RUNTIME_DEPS_DIR, BUILD_DIR)
|
||||
|
||||
console.log("Running npm install in distribution directory...")
|
||||
const cwd = process.cwd()
|
||||
process.chdir(BUILD_DIR)
|
||||
|
||||
try {
|
||||
execSync("npm install", { stdio: "inherit" })
|
||||
// Move the vscode directory into node_modules.
|
||||
// It can't be installed using npm because it will create a symlink which cannot be unzipped correctly on windows.
|
||||
fs.renameSync("vscode", path.join("node_modules", "vscode"))
|
||||
} catch (error) {
|
||||
console.error("Error during setup:", error)
|
||||
process.exit(1)
|
||||
} finally {
|
||||
process.chdir(cwd)
|
||||
}
|
||||
|
||||
// Check for native .node modules.
|
||||
const nativeModules = await glob("**/*.node", { cwd: BUILD_DIR, nodir: true })
|
||||
if (nativeModules.length > 0) {
|
||||
console.error("Native node modules cannot be included in the standalone distribution:\n", nativeModules.join("\n"))
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Zip the build directory (excluding any pre-existing output zip).
|
||||
const zipPath = path.join(BUILD_DIR, "standalone.zip")
|
||||
const output = fs.createWriteStream(zipPath)
|
||||
const archive = archiver("zip", { zlib: { level: 3 } })
|
||||
async function zipDistribution() {
|
||||
// Zip the build directory (excluding any pre-existing output zip).
|
||||
const zipPath = path.join(BUILD_DIR, "standalone.zip")
|
||||
const output = fs.createWriteStream(zipPath)
|
||||
const archive = archiver("zip", { zlib: { level: 3 } })
|
||||
// Use the same ignore file that vscode uses when packaging the extension.
|
||||
const vscodeignore = ignore().add(fs.readFileSync(".vscodeignore", "utf8"))
|
||||
|
||||
output.on("close", () => {
|
||||
console.log(`Created ${zipPath} (${(archive.pointer() / 1024 / 1024).toFixed(1)} MB)`)
|
||||
})
|
||||
archive.on("warning", (err) => {
|
||||
console.warn(`Warning: ${err}`)
|
||||
})
|
||||
archive.on("error", (err) => {
|
||||
throw err
|
||||
})
|
||||
output.on("close", () => {
|
||||
console.log(`Created ${zipPath} (${(archive.pointer() / 1024 / 1024).toFixed(1)} MB)`)
|
||||
})
|
||||
archive.on("warning", (err) => {
|
||||
console.warn(`Warning: ${err}`)
|
||||
})
|
||||
archive.on("error", (err) => {
|
||||
throw err
|
||||
})
|
||||
|
||||
archive.pipe(output)
|
||||
archive.glob("**/*", {
|
||||
cwd: BUILD_DIR,
|
||||
ignore: ["standalone.zip"],
|
||||
})
|
||||
archive.pipe(output)
|
||||
// Add all the files from the standalone build dir.
|
||||
archive.glob("**/*", {
|
||||
cwd: BUILD_DIR,
|
||||
ignore: ["standalone.zip"],
|
||||
})
|
||||
|
||||
// Add the whole cline directory under "extension"
|
||||
archive.directory(process.cwd(), "extension", (entry) => {
|
||||
// Skip certain directories.
|
||||
const exclude = [
|
||||
BUILD_DIR + "/",
|
||||
"node_modules/", // node_modules nearly 1GB.
|
||||
"webview-ui/node_modules/", // node_modules nearly 1GB.
|
||||
]
|
||||
// These node modules are used at runtime as assets, they need to be included.
|
||||
const include = ["node_modules/@vscode/", "webview-ui/node_modules/katex"]
|
||||
const name = entry.name
|
||||
|
||||
if (include.some((prefix) => name.startsWith(prefix))) {
|
||||
// Add the whole cline directory under "extension"
|
||||
archive.directory(process.cwd(), "extension", (entry) => {
|
||||
if (entry.name.startsWith(".git")) {
|
||||
return false
|
||||
}
|
||||
if (entry.name.endsWith(".DS_Store")) {
|
||||
return false
|
||||
}
|
||||
if (entry.name === "dist" || entry.name.startsWith("dist" + path.sep)) {
|
||||
// Don't include the vscode extension build dir.
|
||||
return false
|
||||
}
|
||||
if (vscodeignore.ignores(entry.name)) {
|
||||
// Exclude entries also ignored by the vscode packager.
|
||||
return false
|
||||
}
|
||||
return entry
|
||||
}
|
||||
if (exclude.some((prefix) => name.startsWith(prefix))) {
|
||||
return false
|
||||
}
|
||||
if (name.match(/(^|\/)\./)) {
|
||||
// exclude dot directories
|
||||
return false
|
||||
}
|
||||
return entry
|
||||
})
|
||||
})
|
||||
|
||||
console.log("Zipping package...")
|
||||
await archive.finalize()
|
||||
console.log("Zipping package...")
|
||||
await archive.finalize()
|
||||
}
|
||||
|
||||
/* cp -r */
|
||||
async function cpr(source, dest) {
|
||||
await cp(source, dest, {
|
||||
recursive: true,
|
||||
preserveTimestamps: true,
|
||||
dereference: false, // preserve symlinks instead of following them
|
||||
})
|
||||
}
|
||||
|
||||
await main()
|
||||
|
||||
+25
-2
@@ -38,8 +38,7 @@ export interface SingleCompletionHandler {
|
||||
completePrompt(prompt: string): Promise<string>
|
||||
}
|
||||
|
||||
export function buildApiHandler(configuration: ApiConfiguration): ApiHandler {
|
||||
const { apiProvider, ...options } = configuration
|
||||
function createHandlerForProvider(apiProvider: string | undefined, options: any): ApiHandler {
|
||||
switch (apiProvider) {
|
||||
case "anthropic":
|
||||
return new AnthropicHandler(options)
|
||||
@@ -97,3 +96,27 @@ export function buildApiHandler(configuration: ApiConfiguration): ApiHandler {
|
||||
return new AnthropicHandler(options)
|
||||
}
|
||||
}
|
||||
|
||||
export function buildApiHandler(configuration: ApiConfiguration): ApiHandler {
|
||||
const { apiProvider, ...options } = configuration
|
||||
|
||||
// Validate thinking budget tokens against model's maxTokens to prevent API errors
|
||||
// wrapped in a try-catch for safety, but this should never throw
|
||||
try {
|
||||
if (options.thinkingBudgetTokens && options.thinkingBudgetTokens > 0) {
|
||||
const handler = createHandlerForProvider(apiProvider, options)
|
||||
|
||||
const modelInfo = handler.getModel().info
|
||||
if (modelInfo.maxTokens && options.thinkingBudgetTokens > modelInfo.maxTokens) {
|
||||
const clippedValue = modelInfo.maxTokens - 1
|
||||
options.thinkingBudgetTokens = clippedValue
|
||||
} else {
|
||||
return handler // don't rebuild unless its necessary
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("buildApiHandler error:", error)
|
||||
}
|
||||
|
||||
return createHandlerForProvider(apiProvider, options)
|
||||
}
|
||||
|
||||
@@ -45,8 +45,10 @@ describe("OllamaHandler", () => {
|
||||
this.skip()
|
||||
}
|
||||
this.timeout(5000)
|
||||
// Ensure client is initialized
|
||||
const client = (handler as any).ensureClient()
|
||||
// Mock the Ollama client's chat method
|
||||
const chatStub = sinon.stub(handler["client"], "chat").resolves({
|
||||
const chatStub = sinon.stub(client, "chat").resolves({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
message: { content: "Hello, world!" },
|
||||
@@ -139,8 +141,9 @@ describe("OllamaHandler", () => {
|
||||
// Restore real timers for this test
|
||||
clock.restore()
|
||||
|
||||
// Mock the Ollama client's chat method to fail on first call and succeed on second
|
||||
const chatStub = sinon.stub(handler["client"], "chat")
|
||||
// Ensure client is initialized and mock the Ollama client's chat method to fail on first call and succeed on second
|
||||
const client = (handler as any).ensureClient()
|
||||
const chatStub = sinon.stub(client, "chat")
|
||||
|
||||
// First call throws an error
|
||||
chatStub.onFirstCall().rejects(new Error("API Error"))
|
||||
|
||||
@@ -7,18 +7,33 @@ import { ApiStream } from "../transform/stream"
|
||||
|
||||
export class AnthropicHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: Anthropic
|
||||
private client: Anthropic | undefined
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.client = new Anthropic({
|
||||
apiKey: this.options.apiKey,
|
||||
baseURL: this.options.anthropicBaseUrl || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
private ensureClient(): Anthropic {
|
||||
if (!this.client) {
|
||||
if (!this.options.apiKey) {
|
||||
throw new Error("Anthropic API key is required")
|
||||
}
|
||||
try {
|
||||
this.client = new Anthropic({
|
||||
apiKey: this.options.apiKey,
|
||||
baseURL: this.options.anthropicBaseUrl || undefined,
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(`Error creating Anthropic client: ${error.message}`)
|
||||
}
|
||||
}
|
||||
return this.client
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
|
||||
const model = this.getModel()
|
||||
let stream: AnthropicStream<Anthropic.RawMessageStreamEvent>
|
||||
const modelId = model.id
|
||||
@@ -44,7 +59,7 @@ export class AnthropicHandler implements ApiHandler {
|
||||
)
|
||||
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
|
||||
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
|
||||
stream = await this.client.messages.create(
|
||||
stream = await client.messages.create(
|
||||
{
|
||||
model: modelId,
|
||||
thinking: reasoningOn ? { type: "enabled", budget_tokens: budget_tokens } : undefined,
|
||||
@@ -118,7 +133,7 @@ export class AnthropicHandler implements ApiHandler {
|
||||
break
|
||||
}
|
||||
default: {
|
||||
stream = await this.client.messages.create({
|
||||
stream = await client.messages.create({
|
||||
model: modelId,
|
||||
max_tokens: model.info.maxTokens || 8192,
|
||||
temperature: 0,
|
||||
|
||||
@@ -7,32 +7,52 @@ import { ApiStream } from "@api/transform/stream"
|
||||
|
||||
export class CerebrasHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: Cerebras
|
||||
private client: Cerebras | undefined
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
}
|
||||
|
||||
// Clean and validate the API key
|
||||
const cleanApiKey = this.options.cerebrasApiKey?.trim()
|
||||
private ensureClient(): Cerebras {
|
||||
if (!this.client) {
|
||||
// Clean and validate the API key
|
||||
const cleanApiKey = this.options.cerebrasApiKey?.trim()
|
||||
|
||||
if (!cleanApiKey) {
|
||||
throw new Error("Cerebras API key is required")
|
||||
if (!cleanApiKey) {
|
||||
throw new Error("Cerebras API key is required")
|
||||
}
|
||||
|
||||
try {
|
||||
this.client = new Cerebras({
|
||||
apiKey: cleanApiKey,
|
||||
timeout: 30000, // 30 second timeout
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(`Error creating Cerebras client: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
this.client = new Cerebras({
|
||||
apiKey: cleanApiKey,
|
||||
timeout: 30000, // 30 second timeout
|
||||
})
|
||||
return this.client
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
|
||||
// Convert Anthropic messages to Cerebras format
|
||||
const cerebrasMessages: Array<{
|
||||
role: "system" | "user" | "assistant"
|
||||
content: string
|
||||
}> = [{ role: "system", content: systemPrompt }]
|
||||
|
||||
// Helper function to strip thinking tags from content
|
||||
const stripThinkingTags = (content: string): string => {
|
||||
return content.replace(/<think>[\s\S]*?<\/think>/g, "").trim()
|
||||
}
|
||||
|
||||
// Check if this is a reasoning model that uses thinking tags
|
||||
const modelId = this.getModel().id
|
||||
const isReasoningModel = modelId.includes("qwen") || modelId.includes("deepseek-r1-distill")
|
||||
|
||||
// Convert Anthropic messages to Cerebras format
|
||||
for (const message of messages) {
|
||||
if (message.role === "user") {
|
||||
@@ -50,7 +70,7 @@ export class CerebrasHandler implements ApiHandler {
|
||||
: message.content
|
||||
cerebrasMessages.push({ role: "user", content })
|
||||
} else if (message.role === "assistant") {
|
||||
const content = Array.isArray(message.content)
|
||||
let content = Array.isArray(message.content)
|
||||
? message.content
|
||||
.map((block) => {
|
||||
if (block.type === "text") {
|
||||
@@ -60,12 +80,19 @@ export class CerebrasHandler implements ApiHandler {
|
||||
})
|
||||
.join("\n")
|
||||
: message.content || ""
|
||||
|
||||
// Strip thinking tags from assistant messages for reasoning models
|
||||
// so the model doesn't see its own thinking in the conversation history
|
||||
if (isReasoningModel) {
|
||||
content = stripThinkingTags(content)
|
||||
}
|
||||
|
||||
cerebrasMessages.push({ role: "assistant", content })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await this.client.chat.completions.create({
|
||||
const stream = await client.chat.completions.create({
|
||||
model: this.getModel().id,
|
||||
messages: cerebrasMessages,
|
||||
temperature: 0,
|
||||
@@ -74,8 +101,6 @@ export class CerebrasHandler implements ApiHandler {
|
||||
|
||||
// Handle streaming response
|
||||
let reasoning: string | null = null // Track reasoning content for models that support thinking
|
||||
const modelId = this.getModel().id
|
||||
const isReasoningModel = modelId.includes("qwen") || modelId.includes("deepseek-r1-distill")
|
||||
|
||||
for await (const chunk of stream as any) {
|
||||
// Type assertion for the streaming chunk
|
||||
|
||||
@@ -4,7 +4,7 @@ import { type ApiHandler } from ".."
|
||||
import { ApiStreamUsageChunk, type ApiStream } from "../transform/stream"
|
||||
import { withRetry } from "../retry"
|
||||
import { runClaudeCode } from "@/integrations/claude-code/run"
|
||||
import { ClaudeCodeMessage } from "@/integrations/claude-code/types"
|
||||
import { filterMessagesForClaudeCode } from "@/integrations/claude-code/message-filter"
|
||||
|
||||
export class ClaudeCodeHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
@@ -19,37 +19,15 @@ export class ClaudeCodeHandler implements ApiHandler {
|
||||
maxDelay: 15000,
|
||||
})
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
// Filter out image blocks since Claude Code doesn't support them
|
||||
const filteredMessages = filterMessagesForClaudeCode(messages)
|
||||
|
||||
const claudeProcess = runClaudeCode({
|
||||
systemPrompt,
|
||||
messages,
|
||||
messages: filteredMessages,
|
||||
path: this.options.claudeCodePath,
|
||||
modelId: this.getModel().id,
|
||||
})
|
||||
|
||||
const dataQueue: string[] = []
|
||||
let processError = null
|
||||
let errorOutput = ""
|
||||
let exitCode: number | null = null
|
||||
|
||||
claudeProcess.stdout.on("data", (data) => {
|
||||
const output = data.toString()
|
||||
const lines = output.split("\n").filter((line: string) => line.trim() !== "")
|
||||
|
||||
for (const line of lines) {
|
||||
dataQueue.push(line)
|
||||
}
|
||||
})
|
||||
|
||||
claudeProcess.stderr.on("data", (data) => {
|
||||
errorOutput += data.toString()
|
||||
})
|
||||
|
||||
claudeProcess.on("close", (code) => {
|
||||
exitCode = code
|
||||
})
|
||||
|
||||
claudeProcess.on("error", (error) => {
|
||||
processError = error
|
||||
thinkingBudgetTokens: this.options.thinkingBudgetTokens,
|
||||
})
|
||||
|
||||
// Usage is included with assistant messages,
|
||||
@@ -62,61 +40,75 @@ export class ClaudeCodeHandler implements ApiHandler {
|
||||
cacheWriteTokens: 0,
|
||||
}
|
||||
|
||||
while (exitCode !== 0 || dataQueue.length > 0) {
|
||||
if (dataQueue.length === 0) {
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
}
|
||||
let isPaidUsage = true
|
||||
|
||||
if (exitCode !== null && exitCode !== 0) {
|
||||
throw new Error(
|
||||
`Claude Code process exited with code ${exitCode}.${errorOutput ? ` Error output: ${errorOutput.trim()}` : ""}`,
|
||||
)
|
||||
}
|
||||
|
||||
const data = dataQueue.shift()
|
||||
if (!data) {
|
||||
continue
|
||||
}
|
||||
|
||||
const chunk = this.attemptParseChunk(data)
|
||||
|
||||
if (!chunk) {
|
||||
for await (const chunk of claudeProcess) {
|
||||
if (typeof chunk === "string") {
|
||||
yield {
|
||||
type: "text",
|
||||
text: data || "",
|
||||
text: chunk,
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (chunk.type === "system" && chunk.subtype === "init") {
|
||||
// Based on my tests, subscription usage sets the `apiKeySource` to "none"
|
||||
isPaidUsage = chunk.apiKeySource !== "none"
|
||||
continue
|
||||
}
|
||||
|
||||
if (chunk.type === "assistant" && "message" in chunk) {
|
||||
const message = chunk.message
|
||||
|
||||
if (message.stop_reason !== null && message.stop_reason !== "tool_use") {
|
||||
const errorMessage = message.content[0]?.text || `Claude Code stopped with reason: ${message.stop_reason}`
|
||||
if (message.stop_reason !== null) {
|
||||
const content = "text" in message.content[0] ? message.content[0] : undefined
|
||||
|
||||
if (errorMessage.includes("Invalid model name")) {
|
||||
throw new Error(
|
||||
errorMessage +
|
||||
`\n\nAPI keys and subscription plans allow different models. Make sure the selected model is included in your plan.`,
|
||||
)
|
||||
const isError = content && content.text.startsWith(`API Error`)
|
||||
if (isError) {
|
||||
// Error messages are formatted as: `API Error: <<status code>> <<json>>`
|
||||
const errorMessageStart = content.text.indexOf("{")
|
||||
const errorMessage = content.text.slice(errorMessageStart)
|
||||
|
||||
const error = this.attemptParse(errorMessage)
|
||||
if (!error) {
|
||||
throw new Error(content.text)
|
||||
}
|
||||
|
||||
if (error.error.message.includes("Invalid model name")) {
|
||||
throw new Error(
|
||||
content.text +
|
||||
`\n\nAPI keys and subscription plans allow different models. Make sure the selected model is included in your plan.`,
|
||||
)
|
||||
}
|
||||
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
for (const content of message.content) {
|
||||
if (content.type === "text") {
|
||||
yield {
|
||||
type: "text",
|
||||
text: content.text,
|
||||
}
|
||||
} else {
|
||||
console.warn("Unsupported content type:", content.type)
|
||||
switch (content.type) {
|
||||
case "text":
|
||||
yield {
|
||||
type: "text",
|
||||
text: content.text,
|
||||
}
|
||||
break
|
||||
case "thinking":
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: content.thinking || "",
|
||||
}
|
||||
break
|
||||
case "redacted_thinking":
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: "[Redacted thinking block]",
|
||||
}
|
||||
break
|
||||
case "tool_use":
|
||||
console.error(`tool_use is not supported yet. Received: ${JSON.stringify(content)}`)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,14 +121,18 @@ export class ClaudeCodeHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
if (chunk.type === "result" && "result" in chunk) {
|
||||
usage.totalCost = chunk.cost_usd || 0
|
||||
usage.totalCost = isPaidUsage ? chunk.total_cost_usd : 0
|
||||
|
||||
yield usage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (processError) {
|
||||
throw processError
|
||||
}
|
||||
private attemptParse(str: string) {
|
||||
try {
|
||||
return JSON.parse(str)
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,14 +148,4 @@ export class ClaudeCodeHandler implements ApiHandler {
|
||||
info: claudeCodeModels[claudeCodeDefaultModelId],
|
||||
}
|
||||
}
|
||||
|
||||
// TOOD: Validate instead of parsing
|
||||
private attemptParseChunk(data: string): ClaudeCodeMessage | null {
|
||||
try {
|
||||
return JSON.parse(data)
|
||||
} catch (error) {
|
||||
console.error("Error parsing chunk:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+207
-76
@@ -1,129 +1,260 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { ApiHandler } from "../"
|
||||
import { ClineAccountService } from "@/services/account/ClineAccountService"
|
||||
import { ApiHandlerOptions, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "@shared/api"
|
||||
import { createOpenRouterStream } from "../transform/openrouter-stream"
|
||||
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
|
||||
import axios from "axios"
|
||||
import axios, { AxiosRequestConfig, AxiosResponse } from "axios"
|
||||
import { OpenRouterErrorResponse } from "./types"
|
||||
import { withRetry } from "../retry"
|
||||
import { AuthService } from "@/services/auth/AuthService"
|
||||
|
||||
export class ClineHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
private clineAccountService = ClineAccountService.getInstance()
|
||||
private _authService: AuthService
|
||||
// TODO: replace this with a global API Host
|
||||
private readonly _baseUrl = "https://api.cline.bot"
|
||||
// private readonly _baseUrl = "https://core-api.staging.int.cline.bot"
|
||||
// private readonly _baseUrl = "http://localhost:7777"
|
||||
lastGenerationId?: string
|
||||
private counter = 0
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.client = new OpenAI({
|
||||
baseURL: "https://api.cline.bot/v1",
|
||||
apiKey: this.options.clineApiKey || "",
|
||||
defaultHeaders: {
|
||||
"HTTP-Referer": "https://cline.bot", // Optional, for including your app on cline.bot rankings.
|
||||
"X-Title": "Cline", // Optional. Shows in rankings on cline.bot.
|
||||
"X-Task-ID": this.options.taskId || "", // Include the task ID in the request headers
|
||||
},
|
||||
})
|
||||
this._authService = AuthService.getInstance()
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const clineAccountAuthToken = await this._authService.getAuthToken()
|
||||
|
||||
this.lastGenerationId = undefined
|
||||
|
||||
const stream = await createOpenRouterStream(
|
||||
this.client,
|
||||
systemPrompt,
|
||||
messages,
|
||||
this.getModel(),
|
||||
this.options.reasoningEffort,
|
||||
this.options.thinkingBudgetTokens,
|
||||
this.options.openRouterProviderSorting,
|
||||
const requestConfig: AxiosRequestConfig = {
|
||||
headers: {
|
||||
"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
|
||||
Authorization: `Bearer ${clineAccountAuthToken}`,
|
||||
},
|
||||
timeout: 15_000, // Set a timeout for requests to avoid hanging
|
||||
}
|
||||
|
||||
const me = await this.clineAccountService.fetchMe()
|
||||
console.log(
|
||||
"SwitchAuthToken: Active Organization",
|
||||
me?.organizations.filter((org) => org.active)[0]?.name || "No active organization",
|
||||
)
|
||||
|
||||
let didOutputUsage: boolean = false
|
||||
|
||||
for await (const chunk of stream) {
|
||||
// openrouter returns an error object instead of the openai sdk throwing an error
|
||||
if ("error" in chunk) {
|
||||
const error = chunk.error as OpenRouterErrorResponse["error"]
|
||||
console.error(`Cline API Error: ${error?.code} - ${error?.message}`)
|
||||
// Include metadata in the error message if available
|
||||
const metadataStr = error.metadata ? `\nMetadata: ${JSON.stringify(error.metadata, null, 2)}` : ""
|
||||
throw new Error(`Cline API Error ${error.code}: ${error.message}${metadataStr}`)
|
||||
const url = `${this._baseUrl}/api/v1/chat/completions`
|
||||
try {
|
||||
const response = await axios.post(
|
||||
url,
|
||||
{
|
||||
model: this.getModel().id,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: systemPrompt,
|
||||
},
|
||||
...messages,
|
||||
],
|
||||
stream: false,
|
||||
// reasoning_effort: this.options.reasoningEffort || "low",
|
||||
// thinking_budget_tokens: this.options.thinkingBudgetTokens || 0,
|
||||
// open_router_provider_sorting: this.options.openRouterProviderSorting || "default",
|
||||
},
|
||||
requestConfig,
|
||||
)
|
||||
|
||||
if (!response.data || !response.data.data) {
|
||||
throw new Error(`Request to ${url} failed with status ${response.status}`)
|
||||
}
|
||||
|
||||
if (!this.lastGenerationId && chunk.id) {
|
||||
this.lastGenerationId = chunk.id
|
||||
if (!response.data.data.choices || response.data.data.choices.length === 0) {
|
||||
throw new Error(`No choices returned from Cline API: ${JSON.stringify(response.data)}`)
|
||||
}
|
||||
|
||||
const delta = chunk.choices[0]?.delta
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
for (const choice of response.data.data.choices) {
|
||||
if (choice.finish_reason === "error") {
|
||||
const error = choice.error || { code: "Unknown", message: "No error details provided" }
|
||||
console.error(`Cline API Error: ${error.code} - ${error.message}`)
|
||||
throw new Error(`Cline API Error: ${error.code} - ${error.message}`)
|
||||
}
|
||||
if (choice.delta && choice.delta.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: choice.delta.content,
|
||||
}
|
||||
}
|
||||
if (choice.delta && choice.delta.reasoning) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: choice.delta.reasoning,
|
||||
}
|
||||
}
|
||||
if (choice.message && choice.message.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: choice.message.content,
|
||||
}
|
||||
}
|
||||
if (choice.usage) {
|
||||
const totalCost = choice.usage.cost || 0
|
||||
yield {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: choice.usage.cached_tokens || 0,
|
||||
inputTokens: choice.usage.prompt_tokens || 0,
|
||||
outputTokens: choice.usage.completion_tokens || 0,
|
||||
totalCost,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reasoning tokens are returned separately from the content
|
||||
if ("reasoning" in delta && delta.reasoning) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
// @ts-ignore-next-line
|
||||
reasoning: delta.reasoning,
|
||||
}
|
||||
}
|
||||
|
||||
if (!didOutputUsage && chunk.usage) {
|
||||
// @ts-ignore-next-line
|
||||
let totalCost = chunk.usage.cost || 0
|
||||
const modelId = this.getModel().id
|
||||
const provider = modelId.split("/")[0]
|
||||
|
||||
// If provider is x-ai, set totalCost to 0 (we're doing a promo)
|
||||
if (provider === "x-ai") {
|
||||
totalCost = 0
|
||||
}
|
||||
|
||||
if (response.data.data.usage) {
|
||||
didOutputUsage = true
|
||||
yield {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-ignore-next-line
|
||||
totalCost,
|
||||
cacheReadTokens: response.data.data.usage.prompt_tokens_details?.cached_tokens || 0,
|
||||
inputTokens: response.data.data.usage.prompt_tokens || 0,
|
||||
outputTokens: response.data.data.usage.completion_tokens || 0,
|
||||
totalCost: response.data.data.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
|
||||
// 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
|
||||
// }
|
||||
|
||||
// // Check for mid-stream error via finish_reason
|
||||
// const choice = chunk.choices?.[0]
|
||||
// // OpenRouter may return finish_reason = "error" with error details
|
||||
// if ((choice?.finish_reason as string) === "error") {
|
||||
// const choiceWithError = choice as any
|
||||
// if (choiceWithError.error) {
|
||||
// const error = choiceWithError.error
|
||||
// console.error(`Cline Mid-Stream Error: ${error.code || error.type || "Unknown"} - ${error.message}`)
|
||||
// throw new Error(`Cline Mid-Stream Error: ${error.code || error.type || "Unknown"} - ${error.message}`)
|
||||
// } else {
|
||||
// throw new Error("Cline Mid-Stream Error: Stream terminated with error status but no error details provided")
|
||||
// }
|
||||
// }
|
||||
|
||||
// const delta = choice?.delta
|
||||
// if (delta?.content) {
|
||||
// yield {
|
||||
// type: "text",
|
||||
// text: delta.content,
|
||||
// }
|
||||
// }
|
||||
|
||||
// // Reasoning tokens are returned separately from the content
|
||||
// if ("reasoning" in delta && delta.reasoning) {
|
||||
// yield {
|
||||
// type: "reasoning",
|
||||
// // @ts-ignore-next-line
|
||||
// reasoning: delta.reasoning,
|
||||
// }
|
||||
// }
|
||||
|
||||
// if (!didOutputUsage && chunk.usage) {
|
||||
// // @ts-ignore-next-line
|
||||
// let totalCost = (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0)
|
||||
// const modelId = this.getModel().id
|
||||
// const provider = modelId.split("/")[0]
|
||||
|
||||
// // If provider is x-ai, set totalCost to 0 (we're doing a promo)
|
||||
// if (provider === "x-ai") {
|
||||
// totalCost = 0
|
||||
// }
|
||||
|
||||
// if (modelId.includes("gemini")) {
|
||||
// yield {
|
||||
// type: "usage",
|
||||
// cacheWriteTokens: 0,
|
||||
// cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
|
||||
// inputTokens: (chunk.usage.prompt_tokens || 0) - (chunk.usage.prompt_tokens_details?.cached_tokens || 0),
|
||||
// outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// // @ts-ignore-next-line
|
||||
// totalCost,
|
||||
// }
|
||||
// } else {
|
||||
// yield {
|
||||
// type: "usage",
|
||||
// cacheWriteTokens: 0,
|
||||
// cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
|
||||
// inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
// outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// // @ts-ignore-next-line
|
||||
// totalCost,
|
||||
// }
|
||||
// }
|
||||
// didOutputUsage = true
|
||||
// }
|
||||
// }
|
||||
|
||||
// Fallback to generation endpoint if usage chunk not returned
|
||||
if (!didOutputUsage) {
|
||||
console.warn("Cline API did not return usage chunk, fetching from generation endpoint")
|
||||
// const apiStreamUsage = await this.getApiStreamUsage()
|
||||
// if (apiStreamUsage) {
|
||||
// yield apiStreamUsage
|
||||
// }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Cline API Error:", error)
|
||||
}
|
||||
}
|
||||
|
||||
async getApiStreamUsage(): Promise<ApiStreamUsageChunk | undefined> {
|
||||
if (this.lastGenerationId) {
|
||||
try {
|
||||
const response = await axios.get(`https://api.cline.bot/v1/generation?id=${this.lastGenerationId}`, {
|
||||
// TODO: replace this with firebase auth
|
||||
// TODO: use global API Host
|
||||
|
||||
const response = await axios.get(`${this.clineAccountService.baseUrl}/generation?id=${this.lastGenerationId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.options.clineApiKey}`,
|
||||
Authorization: `Bearer ${this.options.clineAccountId}`,
|
||||
},
|
||||
timeout: 15_000, // this request hangs sometimes
|
||||
})
|
||||
|
||||
const generation = response.data
|
||||
return {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: generation?.native_tokens_cached || 0,
|
||||
inputTokens: generation?.native_tokens_prompt || 0,
|
||||
outputTokens: generation?.native_tokens_completion || 0,
|
||||
totalCost: generation?.total_cost || 0,
|
||||
let modelId = this.options.openRouterModelId
|
||||
if (modelId && modelId.includes("gemini")) {
|
||||
return {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: generation?.native_tokens_cached || 0,
|
||||
// openrouter generation endpoint fails often
|
||||
inputTokens: (generation?.native_tokens_prompt || 0) - (generation?.native_tokens_cached || 0),
|
||||
outputTokens: generation?.native_tokens_completion || 0,
|
||||
totalCost: generation?.total_cost || 0,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: generation?.native_tokens_cached || 0,
|
||||
// openrouter generation endpoint fails often
|
||||
inputTokens: generation?.native_tokens_prompt || 0,
|
||||
outputTokens: generation?.native_tokens_completion || 0,
|
||||
totalCost: generation?.total_cost || 0,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore if fails
|
||||
|
||||
@@ -10,14 +10,27 @@ import { convertToR1Format } from "../transform/r1-format"
|
||||
|
||||
export class DeepSeekHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
private client: OpenAI | undefined
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.client = new OpenAI({
|
||||
baseURL: "https://api.deepseek.com/v1",
|
||||
apiKey: this.options.deepSeekApiKey,
|
||||
})
|
||||
}
|
||||
|
||||
private ensureClient(): OpenAI {
|
||||
if (!this.client) {
|
||||
if (!this.options.deepSeekApiKey) {
|
||||
throw new Error("DeepSeek API key is required")
|
||||
}
|
||||
try {
|
||||
this.client = new OpenAI({
|
||||
baseURL: "https://api.deepseek.com/v1",
|
||||
apiKey: this.options.deepSeekApiKey,
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(`Error creating DeepSeek client: ${error.message}`)
|
||||
}
|
||||
}
|
||||
return this.client
|
||||
}
|
||||
|
||||
private async *yieldUsage(info: ModelInfo, usage: OpenAI.Completions.CompletionUsage | undefined): ApiStream {
|
||||
@@ -54,6 +67,7 @@ export class DeepSeekHandler implements ApiHandler {
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
const model = this.getModel()
|
||||
|
||||
const isDeepseekReasoner = model.id.includes("deepseek-reasoner")
|
||||
@@ -67,7 +81,7 @@ export class DeepSeekHandler implements ApiHandler {
|
||||
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
|
||||
}
|
||||
|
||||
const stream = await this.client.chat.completions.create({
|
||||
const stream = await client.chat.completions.create({
|
||||
model: model.id,
|
||||
max_completion_tokens: model.info.maxTokens,
|
||||
messages: openAiMessages,
|
||||
|
||||
@@ -8,13 +8,26 @@ import { withRetry } from "../retry"
|
||||
|
||||
export class DoubaoHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
private client: OpenAI | undefined
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.client = new OpenAI({
|
||||
baseURL: "https://ark.cn-beijing.volces.com/api/v3/",
|
||||
apiKey: this.options.doubaoApiKey,
|
||||
})
|
||||
}
|
||||
|
||||
private ensureClient(): OpenAI {
|
||||
if (!this.client) {
|
||||
if (!this.options.doubaoApiKey) {
|
||||
throw new Error("Doubao API key is required")
|
||||
}
|
||||
try {
|
||||
this.client = new OpenAI({
|
||||
baseURL: "https://ark.cn-beijing.volces.com/api/v3/",
|
||||
apiKey: this.options.doubaoApiKey,
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(`Error creating Doubao client: ${error.message}`)
|
||||
}
|
||||
}
|
||||
return this.client
|
||||
}
|
||||
|
||||
getModel(): { id: DoubaoModelId; info: ModelInfo } {
|
||||
@@ -31,12 +44,13 @@ export class DoubaoHandler implements ApiHandler {
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
const model = this.getModel()
|
||||
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...convertToOpenAiMessages(messages),
|
||||
]
|
||||
const stream = await this.client.chat.completions.create({
|
||||
const stream = await client.chat.completions.create({
|
||||
model: model.id,
|
||||
max_completion_tokens: model.info.maxTokens,
|
||||
messages: openAiMessages,
|
||||
|
||||
@@ -15,18 +15,32 @@ import { ApiStream } from "../transform/stream"
|
||||
|
||||
export class FireworksHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
private client: OpenAI | undefined
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.client = new OpenAI({
|
||||
baseURL: "https://api.fireworks.ai/inference/v1",
|
||||
apiKey: this.options.fireworksApiKey,
|
||||
})
|
||||
}
|
||||
|
||||
private ensureClient(): OpenAI {
|
||||
if (!this.client) {
|
||||
if (!this.options.fireworksApiKey) {
|
||||
throw new Error("Fireworks API key is required")
|
||||
}
|
||||
try {
|
||||
this.client = new OpenAI({
|
||||
baseURL: "https://api.fireworks.ai/inference/v1",
|
||||
apiKey: this.options.fireworksApiKey,
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(`Error creating Fireworks client: ${error.message}`)
|
||||
}
|
||||
}
|
||||
return this.client
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
const modelId = this.options.fireworksModelId ?? ""
|
||||
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
@@ -34,7 +48,7 @@ export class FireworksHandler implements ApiHandler {
|
||||
...convertToOpenAiMessages(messages),
|
||||
]
|
||||
|
||||
const stream = await this.client.chat.completions.create({
|
||||
const stream = await client.chat.completions.create({
|
||||
model: modelId,
|
||||
...(this.options.fireworksModelMaxCompletionTokens
|
||||
? { max_completion_tokens: this.options.fireworksModelMaxCompletionTokens }
|
||||
|
||||
+35
-18
@@ -38,30 +38,45 @@ interface GeminiHandlerOptions extends ApiHandlerOptions {
|
||||
*/
|
||||
export class GeminiHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: GoogleGenAI
|
||||
private client: GoogleGenAI | undefined
|
||||
|
||||
constructor(options: GeminiHandlerOptions) {
|
||||
// Store the options
|
||||
this.options = options
|
||||
}
|
||||
|
||||
if (options.isVertex) {
|
||||
// Initialize with Vertex AI configuration
|
||||
const project = this.options.vertexProjectId ?? "not-provided"
|
||||
const location = this.options.vertexRegion ?? "not-provided"
|
||||
private ensureClient(): GoogleGenAI {
|
||||
if (!this.client) {
|
||||
const options = this.options as GeminiHandlerOptions
|
||||
|
||||
this.client = new GoogleGenAI({
|
||||
vertexai: true,
|
||||
project,
|
||||
location,
|
||||
})
|
||||
} else {
|
||||
// Initialize with standard API key
|
||||
if (!options.geminiApiKey) {
|
||||
throw new Error("API key is required for Google Gemini when not using Vertex AI")
|
||||
if (options.isVertex) {
|
||||
// Initialize with Vertex AI configuration
|
||||
const project = this.options.vertexProjectId ?? "not-provided"
|
||||
const location = this.options.vertexRegion ?? "not-provided"
|
||||
|
||||
try {
|
||||
this.client = new GoogleGenAI({
|
||||
vertexai: true,
|
||||
project,
|
||||
location,
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(`Error creating Gemini Vertex AI client: ${error.message}`)
|
||||
}
|
||||
} else {
|
||||
// Initialize with standard API key
|
||||
if (!options.geminiApiKey) {
|
||||
throw new Error("API key is required for Google Gemini when not using Vertex AI")
|
||||
}
|
||||
|
||||
try {
|
||||
this.client = new GoogleGenAI({ apiKey: options.geminiApiKey })
|
||||
} catch (error) {
|
||||
throw new Error(`Error creating Gemini client: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
this.client = new GoogleGenAI({ apiKey: options.geminiApiKey })
|
||||
}
|
||||
return this.client
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,6 +95,7 @@ export class GeminiHandler implements ApiHandler {
|
||||
maxDelay: 15000,
|
||||
})
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
const { id: modelId, info } = this.getModel()
|
||||
const contents = messages.map(convertAnthropicMessageToGemini)
|
||||
|
||||
@@ -117,7 +133,7 @@ export class GeminiHandler implements ApiHandler {
|
||||
let lastUsageMetadata: GenerateContentResponseUsageMetadata | undefined
|
||||
|
||||
try {
|
||||
const result = await this.client.models.generateContentStream({
|
||||
const result = await client.models.generateContentStream({
|
||||
model: modelId,
|
||||
contents: contents,
|
||||
config: {
|
||||
@@ -351,6 +367,7 @@ export class GeminiHandler implements ApiHandler {
|
||||
*/
|
||||
async countTokens(content: Array<any>): Promise<number> {
|
||||
try {
|
||||
const client = this.ensureClient()
|
||||
const { id: model } = this.getModel()
|
||||
|
||||
// Convert content to Gemini format
|
||||
@@ -362,7 +379,7 @@ export class GeminiHandler implements ApiHandler {
|
||||
})
|
||||
|
||||
// Use Gemini's token counting API
|
||||
const response = await this.client.models.countTokens({
|
||||
const response = await client.models.countTokens({
|
||||
model,
|
||||
contents: [{ parts: geminiContent }],
|
||||
})
|
||||
|
||||
@@ -8,21 +8,35 @@ import { withRetry } from "../retry"
|
||||
|
||||
export class LiteLlmHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
private client: OpenAI | undefined
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.client = new OpenAI({
|
||||
baseURL: this.options.liteLlmBaseUrl || "http://localhost:4000",
|
||||
apiKey: this.options.liteLlmApiKey || "noop",
|
||||
})
|
||||
}
|
||||
|
||||
private ensureClient(): OpenAI {
|
||||
if (!this.client) {
|
||||
if (!this.options.liteLlmApiKey) {
|
||||
throw new Error("LiteLLM API key is required")
|
||||
}
|
||||
try {
|
||||
this.client = new OpenAI({
|
||||
baseURL: this.options.liteLlmBaseUrl || "http://localhost:4000",
|
||||
apiKey: this.options.liteLlmApiKey || "noop",
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(`Error creating LiteLLM client: ${error.message}`)
|
||||
}
|
||||
}
|
||||
return this.client
|
||||
}
|
||||
|
||||
async calculateCost(prompt_tokens: number, completion_tokens: number): Promise<number | undefined> {
|
||||
// Reference: https://github.com/BerriAI/litellm/blob/122ee634f434014267af104814022af1d9a0882f/litellm/proxy/spend_tracking/spend_management_endpoints.py#L1473
|
||||
const client = this.ensureClient()
|
||||
const modelId = this.options.liteLlmModelId || liteLlmDefaultModelId
|
||||
try {
|
||||
const response = await fetch(`${this.client.baseURL}/spend/calculate`, {
|
||||
const response = await fetch(`${client.baseURL}/spend/calculate`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -54,6 +68,7 @@ export class LiteLlmHandler implements ApiHandler {
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
const formattedMessages = convertToOpenAiMessages(messages)
|
||||
const systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = {
|
||||
role: "system",
|
||||
@@ -101,21 +116,15 @@ export class LiteLlmHandler implements ApiHandler {
|
||||
return message
|
||||
})
|
||||
|
||||
const requestPayload: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming & {
|
||||
metadata?: { cline_task_id: string }
|
||||
} = {
|
||||
const stream = await client.chat.completions.create({
|
||||
model: this.options.liteLlmModelId || liteLlmDefaultModelId,
|
||||
messages: [enhancedSystemMessage, ...enhancedMessages],
|
||||
temperature,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
...(thinkingConfig && { thinking: thinkingConfig }), // Add thinking configuration when applicable
|
||||
...(this.options.taskId && {
|
||||
metadata: { cline_task_id: this.options.taskId },
|
||||
}),
|
||||
}
|
||||
|
||||
const stream = await this.client.chat.completions.create(requestPayload)
|
||||
...(this.options.taskId && { litellm_session_id: `cline-${this.options.taskId}` }), // Add session ID for LiteLLM tracking
|
||||
})
|
||||
|
||||
const inputCost = (await this.calculateCost(1e6, 0)) || 0
|
||||
const outputCost = (await this.calculateCost(0, 1e6)) || 0
|
||||
|
||||
@@ -8,25 +8,36 @@ import { withRetry } from "../retry"
|
||||
|
||||
export class LmStudioHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
private client: OpenAI | undefined
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.client = new OpenAI({
|
||||
baseURL: (this.options.lmStudioBaseUrl || "http://localhost:1234") + "/v1",
|
||||
apiKey: "noop",
|
||||
})
|
||||
}
|
||||
|
||||
private ensureClient(): OpenAI {
|
||||
if (!this.client) {
|
||||
try {
|
||||
this.client = new OpenAI({
|
||||
baseURL: (this.options.lmStudioBaseUrl || "http://localhost:1234") + "/v1",
|
||||
apiKey: "noop",
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(`Error creating LM Studio client: ${error.message}`)
|
||||
}
|
||||
}
|
||||
return this.client
|
||||
}
|
||||
|
||||
@withRetry({ retryAllErrors: true })
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...convertToOpenAiMessages(messages),
|
||||
]
|
||||
|
||||
try {
|
||||
const stream = await this.client.chat.completions.create({
|
||||
const stream = await client.chat.completions.create({
|
||||
model: this.getModel().id,
|
||||
messages: openAiMessages,
|
||||
stream: true,
|
||||
|
||||
@@ -8,18 +8,32 @@ import { ApiStream } from "../transform/stream"
|
||||
|
||||
export class MistralHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: Mistral
|
||||
private client: Mistral | undefined
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.client = new Mistral({
|
||||
apiKey: this.options.mistralApiKey,
|
||||
})
|
||||
}
|
||||
|
||||
private ensureClient(): Mistral {
|
||||
if (!this.client) {
|
||||
if (!this.options.mistralApiKey) {
|
||||
throw new Error("Mistral API key is required")
|
||||
}
|
||||
try {
|
||||
this.client = new Mistral({
|
||||
apiKey: this.options.mistralApiKey,
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(`Error creating Mistral client: ${error.message}`)
|
||||
}
|
||||
}
|
||||
return this.client
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const stream = await this.client.chat
|
||||
const client = this.ensureClient()
|
||||
const stream = await client.chat
|
||||
.stream({
|
||||
model: this.getModel().id,
|
||||
// max_completion_tokens: this.getModel().info.maxTokens,
|
||||
|
||||
@@ -8,24 +8,37 @@ import { convertToR1Format } from "../transform/r1-format"
|
||||
import { nebiusDefaultModelId, nebiusModels, type ModelInfo, type ApiHandlerOptions, type NebiusModelId } from "../../shared/api"
|
||||
|
||||
export class NebiusHandler implements ApiHandler {
|
||||
private client: OpenAI
|
||||
private client: OpenAI | undefined
|
||||
|
||||
constructor(private readonly options: ApiHandlerOptions) {
|
||||
this.client = new OpenAI({
|
||||
baseURL: "https://api.studio.nebius.ai/v1",
|
||||
apiKey: this.options.nebiusApiKey,
|
||||
})
|
||||
constructor(private readonly options: ApiHandlerOptions) {}
|
||||
|
||||
private ensureClient(): OpenAI {
|
||||
if (!this.client) {
|
||||
if (!this.options.nebiusApiKey) {
|
||||
throw new Error("Nebius API key is required")
|
||||
}
|
||||
try {
|
||||
this.client = new OpenAI({
|
||||
baseURL: "https://api.studio.nebius.ai/v1",
|
||||
apiKey: this.options.nebiusApiKey,
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(`Error creating Nebius client: ${error.message}`)
|
||||
}
|
||||
}
|
||||
return this.client
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
const model = this.getModel()
|
||||
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = model.id.includes("DeepSeek-R1")
|
||||
? convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
|
||||
: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)]
|
||||
|
||||
const stream = await this.client.chat.completions.create({
|
||||
const stream = await client.chat.completions.create({
|
||||
model: model.id,
|
||||
messages: openAiMessages,
|
||||
temperature: 0,
|
||||
|
||||
@@ -8,15 +8,26 @@ import { withRetry } from "../retry"
|
||||
|
||||
export class OllamaHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: Ollama
|
||||
private client: Ollama | undefined
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.client = new Ollama({ host: this.options.ollamaBaseUrl || "http://localhost:11434" })
|
||||
}
|
||||
|
||||
private ensureClient(): Ollama {
|
||||
if (!this.client) {
|
||||
try {
|
||||
this.client = new Ollama({ host: this.options.ollamaBaseUrl || "http://localhost:11434" })
|
||||
} catch (error) {
|
||||
throw new Error(`Error creating Ollama client: ${error.message}`)
|
||||
}
|
||||
}
|
||||
return this.client
|
||||
}
|
||||
|
||||
@withRetry({ retryAllErrors: true })
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
const ollamaMessages: Message[] = [{ role: "system", content: systemPrompt }, ...convertToOllamaMessages(messages)]
|
||||
|
||||
try {
|
||||
@@ -27,7 +38,7 @@ export class OllamaHandler implements ApiHandler {
|
||||
})
|
||||
|
||||
// Create the actual API request promise
|
||||
const apiPromise = this.client.chat({
|
||||
const apiPromise = client.chat({
|
||||
model: this.getModel().id,
|
||||
messages: ollamaMessages,
|
||||
stream: true,
|
||||
|
||||
@@ -10,13 +10,26 @@ import type { ChatCompletionReasoningEffort } from "openai/resources/chat/comple
|
||||
|
||||
export class OpenAiNativeHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
private client: OpenAI | undefined
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.client = new OpenAI({
|
||||
apiKey: this.options.openAiNativeApiKey,
|
||||
})
|
||||
}
|
||||
|
||||
private ensureClient(): OpenAI {
|
||||
if (!this.client) {
|
||||
if (!this.options.openAiNativeApiKey) {
|
||||
throw new Error("OpenAI API key is required")
|
||||
}
|
||||
try {
|
||||
this.client = new OpenAI({
|
||||
apiKey: this.options.openAiNativeApiKey,
|
||||
})
|
||||
} catch (error: any) {
|
||||
throw new Error(`Error creating OpenAI client: ${error.message}`)
|
||||
}
|
||||
}
|
||||
return this.client
|
||||
}
|
||||
|
||||
private async *yieldUsage(info: ModelInfo, usage: OpenAI.Completions.CompletionUsage | undefined): ApiStream {
|
||||
@@ -38,6 +51,7 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
const model = this.getModel()
|
||||
|
||||
switch (model.id) {
|
||||
@@ -45,7 +59,7 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
case "o1-preview":
|
||||
case "o1-mini": {
|
||||
// o1 doesn't support streaming, non-1 temp, or system prompt
|
||||
const response = await this.client.chat.completions.create({
|
||||
const response = await client.chat.completions.create({
|
||||
model: model.id,
|
||||
messages: [{ role: "user", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
|
||||
})
|
||||
@@ -61,7 +75,7 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
case "o4-mini":
|
||||
case "o3":
|
||||
case "o3-mini": {
|
||||
const stream = await this.client.chat.completions.create({
|
||||
const stream = await client.chat.completions.create({
|
||||
model: model.id,
|
||||
messages: [{ role: "developer", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
|
||||
stream: true,
|
||||
@@ -85,7 +99,7 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
break
|
||||
}
|
||||
default: {
|
||||
const stream = await this.client.chat.completions.create({
|
||||
const stream = await client.chat.completions.create({
|
||||
model: model.id,
|
||||
// max_completion_tokens: this.getModel().info.maxTokens,
|
||||
temperature: 0,
|
||||
|
||||
+36
-22
@@ -10,35 +10,49 @@ import type { ChatCompletionReasoningEffort } from "openai/resources/chat/comple
|
||||
|
||||
export class OpenAiHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
private client: OpenAI | undefined
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
// Azure API shape slightly differs from the core API shape: https://github.com/openai/openai-node?tab=readme-ov-file#microsoft-azure-openai
|
||||
// Use azureApiVersion to determine if this is an Azure endpoint, since the URL may not always contain 'azure.com'
|
||||
if (
|
||||
this.options.azureApiVersion ||
|
||||
((this.options.openAiBaseUrl?.toLowerCase().includes("azure.com") ||
|
||||
this.options.openAiBaseUrl?.toLowerCase().includes("azure.us")) &&
|
||||
!this.options.openAiModelId?.toLowerCase().includes("deepseek"))
|
||||
) {
|
||||
this.client = new AzureOpenAI({
|
||||
baseURL: this.options.openAiBaseUrl,
|
||||
apiKey: this.options.openAiApiKey,
|
||||
apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion,
|
||||
defaultHeaders: this.options.openAiHeaders,
|
||||
})
|
||||
} else {
|
||||
this.client = new OpenAI({
|
||||
baseURL: this.options.openAiBaseUrl,
|
||||
apiKey: this.options.openAiApiKey,
|
||||
defaultHeaders: this.options.openAiHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
private ensureClient(): OpenAI {
|
||||
if (!this.client) {
|
||||
if (!this.options.openAiApiKey) {
|
||||
throw new Error("OpenAI API key is required")
|
||||
}
|
||||
try {
|
||||
// Azure API shape slightly differs from the core API shape: https://github.com/openai/openai-node?tab=readme-ov-file#microsoft-azure-openai
|
||||
// Use azureApiVersion to determine if this is an Azure endpoint, since the URL may not always contain 'azure.com'
|
||||
if (
|
||||
this.options.azureApiVersion ||
|
||||
((this.options.openAiBaseUrl?.toLowerCase().includes("azure.com") ||
|
||||
this.options.openAiBaseUrl?.toLowerCase().includes("azure.us")) &&
|
||||
!this.options.openAiModelId?.toLowerCase().includes("deepseek"))
|
||||
) {
|
||||
this.client = new AzureOpenAI({
|
||||
baseURL: this.options.openAiBaseUrl,
|
||||
apiKey: this.options.openAiApiKey,
|
||||
apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion,
|
||||
defaultHeaders: this.options.openAiHeaders,
|
||||
})
|
||||
} else {
|
||||
this.client = new OpenAI({
|
||||
baseURL: this.options.openAiBaseUrl,
|
||||
apiKey: this.options.openAiApiKey,
|
||||
defaultHeaders: this.options.openAiHeaders,
|
||||
})
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw new Error(`Error creating OpenAI client: ${error.message}`)
|
||||
}
|
||||
}
|
||||
return this.client
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
const modelId = this.options.openAiModelId ?? ""
|
||||
const isDeepseekReasoner = modelId.includes("deepseek-reasoner")
|
||||
const isR1FormatRequired = this.options.openAiModelInfo?.isR1FormatRequired ?? false
|
||||
@@ -68,7 +82,7 @@ export class OpenAiHandler implements ApiHandler {
|
||||
reasoningEffort = (this.options.reasoningEffort as ChatCompletionReasoningEffort) || "medium"
|
||||
}
|
||||
|
||||
const stream = await this.client.chat.completions.create({
|
||||
const stream = await client.chat.completions.create({
|
||||
model: modelId,
|
||||
messages: openAiMessages,
|
||||
temperature,
|
||||
|
||||
@@ -11,27 +11,41 @@ import { OpenRouterErrorResponse } from "./types"
|
||||
|
||||
export class OpenRouterHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
private client: OpenAI | undefined
|
||||
lastGenerationId?: string
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.client = new OpenAI({
|
||||
baseURL: "https://openrouter.ai/api/v1",
|
||||
apiKey: this.options.openRouterApiKey,
|
||||
defaultHeaders: {
|
||||
"HTTP-Referer": "https://cline.bot", // Optional, for including your app on openrouter.ai rankings.
|
||||
"X-Title": "Cline", // Optional. Shows in rankings on openrouter.ai.
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private ensureClient(): OpenAI {
|
||||
if (!this.client) {
|
||||
if (!this.options.openRouterApiKey) {
|
||||
throw new Error("OpenRouter API key is required")
|
||||
}
|
||||
try {
|
||||
this.client = new OpenAI({
|
||||
baseURL: "https://openrouter.ai/api/v1",
|
||||
apiKey: this.options.openRouterApiKey,
|
||||
defaultHeaders: {
|
||||
"HTTP-Referer": "https://cline.bot", // Optional, for including your app on openrouter.ai rankings.
|
||||
"X-Title": "Cline", // Optional. Shows in rankings on openrouter.ai.
|
||||
},
|
||||
})
|
||||
} catch (error: any) {
|
||||
throw new Error(`Error creating OpenRouter client: ${error.message}`)
|
||||
}
|
||||
}
|
||||
return this.client
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
this.lastGenerationId = undefined
|
||||
|
||||
const stream = await createOpenRouterStream(
|
||||
this.client,
|
||||
client,
|
||||
systemPrompt,
|
||||
messages,
|
||||
this.getModel(),
|
||||
@@ -44,6 +58,7 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
|
||||
for await (const chunk of stream) {
|
||||
// openrouter returns an error object instead of the openai sdk throwing an error
|
||||
// Check for error field directly on chunk
|
||||
if ("error" in chunk) {
|
||||
const error = chunk.error as OpenRouterErrorResponse["error"]
|
||||
console.error(`OpenRouter API Error: ${error?.code} - ${error?.message}`)
|
||||
@@ -52,6 +67,29 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
throw new Error(`OpenRouter API Error ${error.code}: ${error.message}${metadataStr}`)
|
||||
}
|
||||
|
||||
// Check for error in choices[0].finish_reason
|
||||
// OpenRouter may return errors in a non-standard way within choices
|
||||
const choice = chunk.choices?.[0]
|
||||
// Use type assertion since OpenRouter uses non-standard "error" finish_reason
|
||||
if ((choice?.finish_reason as string) === "error") {
|
||||
// Use type assertion since OpenRouter adds non-standard error property
|
||||
const choiceWithError = choice as any
|
||||
if (choiceWithError.error) {
|
||||
const error = choiceWithError.error
|
||||
console.error(
|
||||
`OpenRouter Mid-Stream Error: ${error?.code || "Unknown"} - ${error?.message || "Unknown error"}`,
|
||||
)
|
||||
// Format error details
|
||||
const errorDetails = typeof error === "object" ? JSON.stringify(error, null, 2) : String(error)
|
||||
throw new Error(`OpenRouter Mid-Stream Error: ${errorDetails}`)
|
||||
} else {
|
||||
// Fallback if error details are not available
|
||||
throw new Error(
|
||||
`OpenRouter Mid-Stream Error: Stream terminated with error status but no error details provided`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.lastGenerationId && chunk.id) {
|
||||
this.lastGenerationId = chunk.id
|
||||
}
|
||||
@@ -74,14 +112,27 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
if (!didOutputUsage && chunk.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-ignore-next-line
|
||||
totalCost: chunk.usage.cost || 0,
|
||||
let modelId = this.options.openRouterModelId
|
||||
if (modelId && modelId.includes("gemini")) {
|
||||
yield {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
|
||||
inputTokens: (chunk.usage.prompt_tokens || 0) - (chunk.usage.prompt_tokens_details?.cached_tokens || 0),
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-ignore-next-line
|
||||
totalCost: (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0),
|
||||
}
|
||||
} else {
|
||||
yield {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-ignore-next-line
|
||||
totalCost: (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0),
|
||||
}
|
||||
}
|
||||
didOutputUsage = true
|
||||
}
|
||||
@@ -103,14 +154,27 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
const generationIterator = this.fetchGenerationDetails(this.lastGenerationId)
|
||||
const generation = (await generationIterator.next()).value
|
||||
// console.log("OpenRouter generation details:", generation)
|
||||
return {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: generation?.native_tokens_cached || 0,
|
||||
// openrouter generation endpoint fails often
|
||||
inputTokens: generation?.native_tokens_prompt || 0,
|
||||
outputTokens: generation?.native_tokens_completion || 0,
|
||||
totalCost: generation?.total_cost || 0,
|
||||
let modelId = this.options.openRouterModelId
|
||||
if (modelId && modelId.includes("gemini")) {
|
||||
return {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: generation?.native_tokens_cached || 0,
|
||||
// openrouter generation endpoint fails often
|
||||
inputTokens: (generation?.native_tokens_prompt || 0) - (generation?.native_tokens_cached || 0),
|
||||
outputTokens: generation?.native_tokens_completion || 0,
|
||||
totalCost: generation?.total_cost || 0,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: generation?.native_tokens_cached || 0,
|
||||
// openrouter generation endpoint fails often
|
||||
inputTokens: generation?.native_tokens_prompt || 0,
|
||||
outputTokens: generation?.native_tokens_completion || 0,
|
||||
totalCost: generation?.total_cost || 0,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore if fails
|
||||
|
||||
@@ -18,17 +18,30 @@ import { withRetry } from "../retry"
|
||||
|
||||
export class QwenHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
private client: OpenAI | undefined
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.client = new OpenAI({
|
||||
baseURL:
|
||||
this.options.qwenApiLine === "china"
|
||||
? "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
|
||||
apiKey: this.options.qwenApiKey,
|
||||
})
|
||||
}
|
||||
|
||||
private ensureClient(): OpenAI {
|
||||
if (!this.client) {
|
||||
if (!this.options.qwenApiKey) {
|
||||
throw new Error("Alibaba API key is required")
|
||||
}
|
||||
try {
|
||||
this.client = new OpenAI({
|
||||
baseURL:
|
||||
this.options.qwenApiLine === "china"
|
||||
? "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
|
||||
apiKey: this.options.qwenApiKey,
|
||||
})
|
||||
} catch (error: any) {
|
||||
throw new Error(`Error creating Alibaba client: ${error.message}`)
|
||||
}
|
||||
}
|
||||
return this.client
|
||||
}
|
||||
|
||||
getModel(): { id: MainlandQwenModelId | InternationalQwenModelId; info: ModelInfo } {
|
||||
@@ -51,6 +64,7 @@ export class QwenHandler implements ApiHandler {
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
const model = this.getModel()
|
||||
const isDeepseekReasoner = model.id.includes("deepseek-r1")
|
||||
const isReasoningModelFamily = model.id.includes("qwen3") || ["qwen-plus-latest", "qwen-turbo-latest"].includes(model.id)
|
||||
@@ -76,7 +90,7 @@ export class QwenHandler implements ApiHandler {
|
||||
temperature = undefined
|
||||
}
|
||||
|
||||
const stream = await this.client.chat.completions.create({
|
||||
const stream = await client.chat.completions.create({
|
||||
model: model.id,
|
||||
max_completion_tokens: model.info.maxTokens,
|
||||
messages: openAiMessages,
|
||||
|
||||
@@ -19,22 +19,36 @@ interface RequestyUsage extends OpenAI.CompletionUsage {
|
||||
|
||||
export class RequestyHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
private client: OpenAI | undefined
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.client = new OpenAI({
|
||||
baseURL: "https://router.requesty.ai/v1",
|
||||
apiKey: this.options.requestyApiKey,
|
||||
defaultHeaders: {
|
||||
"HTTP-Referer": "https://cline.bot",
|
||||
"X-Title": "Cline",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private ensureClient(): OpenAI {
|
||||
if (!this.client) {
|
||||
if (!this.options.requestyApiKey) {
|
||||
throw new Error("Requesty API key is required")
|
||||
}
|
||||
try {
|
||||
this.client = new OpenAI({
|
||||
baseURL: "https://router.requesty.ai/v1",
|
||||
apiKey: this.options.requestyApiKey,
|
||||
defaultHeaders: {
|
||||
"HTTP-Referer": "https://cline.bot",
|
||||
"X-Title": "Cline",
|
||||
},
|
||||
})
|
||||
} catch (error: any) {
|
||||
throw new Error(`Error creating Requesty client: ${error.message}`)
|
||||
}
|
||||
}
|
||||
return this.client
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
const model = this.getModel()
|
||||
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
@@ -57,7 +71,7 @@ export class RequestyHandler implements ApiHandler {
|
||||
: {}
|
||||
|
||||
// @ts-ignore-next-line
|
||||
const stream = await this.client.chat.completions.create({
|
||||
const stream = await client.chat.completions.create({
|
||||
model: model.id,
|
||||
max_tokens: model.info.maxTokens || undefined,
|
||||
messages: openAiMessages,
|
||||
|
||||
@@ -9,18 +9,32 @@ import { convertToR1Format } from "@api/transform/r1-format"
|
||||
|
||||
export class SambanovaHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
private client: OpenAI | undefined
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.client = new OpenAI({
|
||||
baseURL: "https://api.sambanova.ai/v1",
|
||||
apiKey: this.options.sambanovaApiKey,
|
||||
})
|
||||
}
|
||||
|
||||
private ensureClient(): OpenAI {
|
||||
if (!this.client) {
|
||||
if (!this.options.sambanovaApiKey) {
|
||||
throw new Error("SambaNova API key is required")
|
||||
}
|
||||
try {
|
||||
this.client = new OpenAI({
|
||||
baseURL: "https://api.sambanova.ai/v1",
|
||||
apiKey: this.options.sambanovaApiKey,
|
||||
})
|
||||
} catch (error: any) {
|
||||
throw new Error(`Error creating SambaNova client: ${error.message}`)
|
||||
}
|
||||
}
|
||||
return this.client
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
const model = this.getModel()
|
||||
|
||||
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
@@ -34,7 +48,7 @@ export class SambanovaHandler implements ApiHandler {
|
||||
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
|
||||
}
|
||||
|
||||
const stream = await this.client.chat.completions.create({
|
||||
const stream = await client.chat.completions.create({
|
||||
model: this.getModel().id,
|
||||
messages: openAiMessages,
|
||||
temperature: 0,
|
||||
|
||||
@@ -60,6 +60,7 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"AI-Resource-Group": this.options.sapAiResourceGroup || "default",
|
||||
"Content-Type": "application/json",
|
||||
"AI-Client-Type": "Cline",
|
||||
}
|
||||
|
||||
const url = `${this.options.sapAiCoreBaseUrl}/v2/lm/deployments?$top=10000&$skip=0`
|
||||
@@ -116,12 +117,15 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"AI-Resource-Group": this.options.sapAiResourceGroup || "default",
|
||||
"Content-Type": "application/json",
|
||||
"AI-Client-Type": "Cline",
|
||||
}
|
||||
|
||||
const model = this.getModel()
|
||||
const deploymentId = await this.getDeploymentForModel(model.id)
|
||||
|
||||
const anthropicModels = [
|
||||
"anthropic--claude-4-sonnet",
|
||||
"anthropic--claude-4-opus",
|
||||
"anthropic--claude-3.7-sonnet",
|
||||
"anthropic--claude-3.5-sonnet",
|
||||
"anthropic--claude-3-sonnet",
|
||||
@@ -131,12 +135,18 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
|
||||
const openAIModels = ["gpt-4o", "gpt-4", "gpt-4o-mini", "o1", "gpt-4.1", "gpt-4.1-nano", "o3-mini", "o3", "o4-mini"]
|
||||
|
||||
const geminiModels = ["gemini-2.5-flash", "gemini-2.5-pro"]
|
||||
|
||||
let url: string
|
||||
let payload: any
|
||||
if (anthropicModels.includes(model.id)) {
|
||||
url = `${this.options.sapAiCoreBaseUrl}/v2/inference/deployments/${deploymentId}/invoke-with-response-stream`
|
||||
|
||||
if (model.id === "anthropic--claude-3.7-sonnet") {
|
||||
if (
|
||||
model.id === "anthropic--claude-4-sonnet" ||
|
||||
model.id === "anthropic--claude-4-opus" ||
|
||||
model.id === "anthropic--claude-3.7-sonnet"
|
||||
) {
|
||||
url = `${this.options.sapAiCoreBaseUrl}/v2/inference/deployments/${deploymentId}/converse-stream`
|
||||
payload = {
|
||||
inferenceConfig: {
|
||||
@@ -181,6 +191,9 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
delete payload.stream
|
||||
delete payload.stream_options
|
||||
}
|
||||
} else if (geminiModels.includes(model.id)) {
|
||||
url = `${this.options.sapAiCoreBaseUrl}/v2/inference/deployments/${deploymentId}/models/${model.id}:streamGenerateContent`
|
||||
payload = this.convertToGeminiFormat(systemPrompt, messages)
|
||||
} else {
|
||||
throw new Error(`Unsupported model: ${model.id}`)
|
||||
}
|
||||
@@ -221,8 +234,14 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
}
|
||||
} else if (openAIModels.includes(model.id)) {
|
||||
yield* this.streamCompletionGPT(response.data, model)
|
||||
} else if (model.id === "anthropic--claude-3.7-sonnet") {
|
||||
} else if (
|
||||
model.id === "anthropic--claude-4-sonnet" ||
|
||||
model.id === "anthropic--claude-4-opus" ||
|
||||
model.id === "anthropic--claude-3.7-sonnet"
|
||||
) {
|
||||
yield* this.streamCompletionSonnet37(response.data, model)
|
||||
} else if (geminiModels.includes(model.id)) {
|
||||
yield* this.streamCompletionGemini(response.data, model)
|
||||
} else {
|
||||
yield* this.streamCompletion(response.data, model)
|
||||
}
|
||||
@@ -266,7 +285,6 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
const jsonData = line.slice(6)
|
||||
try {
|
||||
const data = JSON.parse(jsonData)
|
||||
console.log("Received data:", data)
|
||||
if (data.type === "message_start") {
|
||||
usage.input_tokens = data.message.usage.input_tokens
|
||||
yield {
|
||||
@@ -329,7 +347,6 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
try {
|
||||
// Parse the incoming JSON data from the stream
|
||||
const data = JSON.parse(toStrictJson(jsonData))
|
||||
console.log("Received data:", data)
|
||||
|
||||
// Handle metadata (token usage)
|
||||
if (data.metadata?.usage) {
|
||||
@@ -405,7 +422,6 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
const jsonData = line.slice(6)
|
||||
try {
|
||||
const data = JSON.parse(jsonData)
|
||||
console.log("Received GPT data:", data)
|
||||
|
||||
if (data.choices && data.choices.length > 0) {
|
||||
const choice = data.choices[0]
|
||||
@@ -429,7 +445,7 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
if (data.choices && data.choices[0].finish_reason === "stop") {
|
||||
if (data.choices?.[0]?.finish_reason === "stop") {
|
||||
// Final usage yield, if not already provided
|
||||
if (!data.usage) {
|
||||
yield {
|
||||
@@ -451,6 +467,88 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private async *streamCompletionGemini(
|
||||
stream: any,
|
||||
model: { id: SapAiCoreModelId; info: ModelInfo },
|
||||
): AsyncGenerator<any, void, unknown> {
|
||||
let promptTokens = 0
|
||||
let outputTokens = 0
|
||||
let cacheReadTokens = 0
|
||||
let thoughtsTokenCount = 0
|
||||
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
const lines = chunk.toString().split("\n").filter(Boolean)
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const jsonData = line.slice(6)
|
||||
try {
|
||||
const data = JSON.parse(jsonData)
|
||||
const candidateForThoughts = data?.candidates?.[0]
|
||||
const partsForThoughts = candidateForThoughts?.content?.parts
|
||||
let thoughts = ""
|
||||
|
||||
if (partsForThoughts) {
|
||||
for (const part of partsForThoughts) {
|
||||
const { thought, text } = part
|
||||
if (thought && text) {
|
||||
thoughts += text + "\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (thoughts.trim() !== "") {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: thoughts.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
if (data.text) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: data.text,
|
||||
}
|
||||
}
|
||||
|
||||
if (data.candidates && data.candidates[0]?.content?.parts) {
|
||||
for (const part of data.candidates[0].content.parts) {
|
||||
if (part.text && !part.thought) {
|
||||
// Only non-thought text
|
||||
yield {
|
||||
type: "text",
|
||||
text: part.text,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.usageMetadata) {
|
||||
promptTokens = data.usageMetadata.promptTokenCount ?? promptTokens
|
||||
outputTokens = data.usageMetadata.candidatesTokenCount ?? outputTokens
|
||||
thoughtsTokenCount = data.usageMetadata.thoughtsTokenCount ?? thoughtsTokenCount
|
||||
cacheReadTokens = data.usageMetadata.cachedContentTokenCount ?? cacheReadTokens
|
||||
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: promptTokens - cacheReadTokens,
|
||||
outputTokens,
|
||||
thoughtsTokenCount,
|
||||
cacheReadTokens,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to parse Gemini JSON data:", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error streaming Gemini completion:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
createUserReadableRequest(
|
||||
userContent: Array<
|
||||
Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolUseBlockParam | Anthropic.ToolResultBlockParam
|
||||
@@ -485,6 +583,50 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
throw new Error(`Unsupported image format: ${format}`)
|
||||
}
|
||||
|
||||
private convertToGeminiFormat(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]) {
|
||||
const contents = messages.map(this.convertAnthropicMessageToGemini)
|
||||
|
||||
const payload = {
|
||||
contents,
|
||||
systemInstruction: {
|
||||
parts: [
|
||||
{
|
||||
text: systemPrompt,
|
||||
},
|
||||
],
|
||||
},
|
||||
generationConfig: {
|
||||
maxOutputTokens: this.getModel().info.maxTokens,
|
||||
temperature: 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
private convertAnthropicMessageToGemini(message: Anthropic.Messages.MessageParam) {
|
||||
const role = message.role === "assistant" ? "model" : "user"
|
||||
const parts = []
|
||||
|
||||
if (typeof message.content === "string") {
|
||||
parts.push({ text: message.content })
|
||||
} else if (Array.isArray(message.content)) {
|
||||
for (const block of message.content) {
|
||||
if (block.type === "text") {
|
||||
parts.push({ text: block.text })
|
||||
} else if (block.type === "image") {
|
||||
parts.push({
|
||||
inlineData: {
|
||||
mimeType: block.source.media_type,
|
||||
data: block.source.data,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { role, parts }
|
||||
}
|
||||
private formatAnthropicMessages(messages: Anthropic.Messages.MessageParam[]): any[] {
|
||||
return messages.map((m) => {
|
||||
const contentBlocks: any[] = []
|
||||
|
||||
@@ -9,18 +9,32 @@ import { convertToR1Format } from "@api/transform/r1-format"
|
||||
|
||||
export class TogetherHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
private client: OpenAI | undefined
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.client = new OpenAI({
|
||||
baseURL: "https://api.together.xyz/v1",
|
||||
apiKey: this.options.togetherApiKey,
|
||||
})
|
||||
}
|
||||
|
||||
private ensureClient(): OpenAI {
|
||||
if (!this.client) {
|
||||
if (!this.options.togetherApiKey) {
|
||||
throw new Error("Together API key is required")
|
||||
}
|
||||
try {
|
||||
this.client = new OpenAI({
|
||||
baseURL: "https://api.together.xyz/v1",
|
||||
apiKey: this.options.togetherApiKey,
|
||||
})
|
||||
} catch (error: any) {
|
||||
throw new Error(`Error creating Together client: ${error.message}`)
|
||||
}
|
||||
}
|
||||
return this.client
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
const modelId = this.options.togetherModelId ?? ""
|
||||
const isDeepseekReasoner = modelId.includes("deepseek-reasoner")
|
||||
|
||||
@@ -33,7 +47,7 @@ export class TogetherHandler implements ApiHandler {
|
||||
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
|
||||
}
|
||||
|
||||
const stream = await this.client.chat.completions.create({
|
||||
const stream = await client.chat.completions.create({
|
||||
model: modelId,
|
||||
messages: openAiMessages,
|
||||
temperature: 0,
|
||||
|
||||
+43
-16
@@ -7,25 +7,49 @@ import { ApiStream } from "@api/transform/stream"
|
||||
import { GeminiHandler } from "./gemini"
|
||||
|
||||
export class VertexHandler implements ApiHandler {
|
||||
private geminiHandler: GeminiHandler
|
||||
private clientAnthropic: AnthropicVertex
|
||||
private geminiHandler: GeminiHandler | undefined
|
||||
private clientAnthropic: AnthropicVertex | undefined
|
||||
private options: ApiHandlerOptions
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
}
|
||||
|
||||
// Create a GeminiHandler with isVertex flag for Gemini models
|
||||
this.geminiHandler = new GeminiHandler({
|
||||
...options,
|
||||
isVertex: true,
|
||||
})
|
||||
private ensureGeminiHandler(): GeminiHandler {
|
||||
if (!this.geminiHandler) {
|
||||
try {
|
||||
// Create a GeminiHandler with isVertex flag for Gemini models
|
||||
this.geminiHandler = new GeminiHandler({
|
||||
...this.options,
|
||||
isVertex: true,
|
||||
})
|
||||
} catch (error: any) {
|
||||
throw new Error(`Error creating Vertex AI Gemini handler: ${error.message}`)
|
||||
}
|
||||
}
|
||||
return this.geminiHandler
|
||||
}
|
||||
|
||||
// Initialize Anthropic client for Claude models
|
||||
this.clientAnthropic = new AnthropicVertex({
|
||||
projectId: this.options.vertexProjectId,
|
||||
// https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude#regions
|
||||
region: this.options.vertexRegion,
|
||||
})
|
||||
private ensureAnthropicClient(): AnthropicVertex {
|
||||
if (!this.clientAnthropic) {
|
||||
if (!this.options.vertexProjectId) {
|
||||
throw new Error("Vertex AI project ID is required")
|
||||
}
|
||||
if (!this.options.vertexRegion) {
|
||||
throw new Error("Vertex AI region is required")
|
||||
}
|
||||
try {
|
||||
// Initialize Anthropic client for Claude models
|
||||
this.clientAnthropic = new AnthropicVertex({
|
||||
projectId: this.options.vertexProjectId,
|
||||
// https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude#regions
|
||||
region: this.options.vertexRegion,
|
||||
})
|
||||
} catch (error: any) {
|
||||
throw new Error(`Error creating Vertex AI Anthropic client: ${error.message}`)
|
||||
}
|
||||
}
|
||||
return this.clientAnthropic
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
@@ -35,10 +59,13 @@ export class VertexHandler implements ApiHandler {
|
||||
|
||||
// For Gemini models, use the GeminiHandler
|
||||
if (!modelId.includes("claude")) {
|
||||
yield* this.geminiHandler.createMessage(systemPrompt, messages)
|
||||
const geminiHandler = this.ensureGeminiHandler()
|
||||
yield* geminiHandler.createMessage(systemPrompt, messages)
|
||||
return
|
||||
}
|
||||
|
||||
const clientAnthropic = this.ensureAnthropicClient()
|
||||
|
||||
// Claude implementation
|
||||
let budget_tokens = this.options.thinkingBudgetTokens || 0
|
||||
const reasoningOn =
|
||||
@@ -63,7 +90,7 @@ export class VertexHandler implements ApiHandler {
|
||||
)
|
||||
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
|
||||
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
|
||||
stream = await this.clientAnthropic.beta.messages.create(
|
||||
stream = await clientAnthropic.beta.messages.create(
|
||||
{
|
||||
model: modelId,
|
||||
max_tokens: model.info.maxTokens || 8192,
|
||||
@@ -125,7 +152,7 @@ export class VertexHandler implements ApiHandler {
|
||||
break
|
||||
}
|
||||
default: {
|
||||
stream = await this.clientAnthropic.beta.messages.create({
|
||||
stream = await clientAnthropic.beta.messages.create({
|
||||
model: modelId,
|
||||
max_tokens: model.info.maxTokens || 8192,
|
||||
temperature: 0,
|
||||
|
||||
@@ -9,18 +9,32 @@ import { withRetry } from "../retry"
|
||||
|
||||
export class XAIHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
private client: OpenAI | undefined
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.client = new OpenAI({
|
||||
baseURL: "https://api.x.ai/v1",
|
||||
apiKey: this.options.xaiApiKey,
|
||||
})
|
||||
}
|
||||
|
||||
private ensureClient(): OpenAI {
|
||||
if (!this.client) {
|
||||
if (!this.options.xaiApiKey) {
|
||||
throw new Error("xAI API key is required")
|
||||
}
|
||||
try {
|
||||
this.client = new OpenAI({
|
||||
baseURL: "https://api.x.ai/v1",
|
||||
apiKey: this.options.xaiApiKey,
|
||||
})
|
||||
} catch (error: any) {
|
||||
throw new Error(`Error creating xAI client: ${error.message}`)
|
||||
}
|
||||
}
|
||||
return this.client
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
const modelId = this.getModel().id
|
||||
// ensure reasoning effort is either "low" or "high" for grok-3-mini
|
||||
let reasoningEffort: ChatCompletionReasoningEffort | undefined
|
||||
@@ -30,7 +44,7 @@ export class XAIHandler implements ApiHandler {
|
||||
reasoningEffort = undefined
|
||||
}
|
||||
}
|
||||
const stream = await this.client.chat.completions.create({
|
||||
const stream = await client.chat.completions.create({
|
||||
model: modelId,
|
||||
max_completion_tokens: this.getModel().info.maxTokens,
|
||||
temperature: 0,
|
||||
|
||||
@@ -156,6 +156,45 @@ replaced
|
||||
expected: "line2\nreplaced\nline4",
|
||||
isFinal: true,
|
||||
},
|
||||
{
|
||||
name: "malformed diff - missing separator",
|
||||
original: "line1\nline2\nline3",
|
||||
diff: `------- SEARCH
|
||||
line2
|
||||
+++++++ REPLACE
|
||||
replaced`,
|
||||
shouldThrow: true,
|
||||
},
|
||||
{
|
||||
name: "malformed diff - trailing space on separator",
|
||||
original: "line1\nline2\nline3",
|
||||
diff: `------- SEARCH
|
||||
line2
|
||||
=======
|
||||
replaced
|
||||
+++++++ REPLACE`,
|
||||
shouldThrow: true,
|
||||
},
|
||||
{
|
||||
name: "malformed diff - double replace markers",
|
||||
original: "line1\nline2\nline3",
|
||||
diff: `------- SEARCH
|
||||
line2
|
||||
+++++++ REPLACE
|
||||
first replacement
|
||||
+++++++ REPLACE`,
|
||||
shouldThrow: true,
|
||||
},
|
||||
{
|
||||
name: "malformed diff - malformed separator with dashes",
|
||||
original: "line1\nline2\nline3",
|
||||
diff: `------- SEARCH
|
||||
line2
|
||||
------- =======
|
||||
replaced
|
||||
+++++++ REPLACE`,
|
||||
shouldThrow: true,
|
||||
},
|
||||
]
|
||||
//.filter(({name}) => name === "multiple ordered replacements")
|
||||
//.filter(({name}) => name === "delete then replace")
|
||||
|
||||
@@ -8,11 +8,13 @@ const LEGACY_SEARCH_BLOCK_CHAR = "<"
|
||||
const LEGACY_REPLACE_BLOCK_CHAR = ">"
|
||||
|
||||
// Replace the exact string constants with flexible regex patterns
|
||||
const SEARCH_BLOCK_START_REGEX = /^[-]{3,} SEARCH$/
|
||||
const SEARCH_BLOCK_START_REGEX = /^[-]{3,} SEARCH>?$/
|
||||
const LEGACY_SEARCH_BLOCK_START_REGEX = /^[<]{3,} SEARCH>?$/
|
||||
|
||||
const SEARCH_BLOCK_END_REGEX = /^[=]{3,}$/
|
||||
const REPLACE_BLOCK_END_REGEX = /^[+]{3,} REPLACE$/
|
||||
const LEGACY_SEARCH_BLOCK_START_REGEX = /^[<]{3,} SEARCH$/
|
||||
const LEGACY_REPLACE_BLOCK_END_REGEX = /^[>]{3,} REPLACE$/
|
||||
|
||||
const REPLACE_BLOCK_END_REGEX = /^[+]{3,} REPLACE>?$/
|
||||
const LEGACY_REPLACE_BLOCK_END_REGEX = /^[>]{3,} REPLACE>?$/
|
||||
|
||||
// Helper functions to check if a line matches the flexible patterns
|
||||
function isSearchBlockStart(line: string): boolean {
|
||||
@@ -378,6 +380,10 @@ async function constructNewFileContentV1(diffContent: string, originalContent: s
|
||||
if (isReplaceBlockEnd(line)) {
|
||||
// Finished one replace block
|
||||
|
||||
if (searchMatchIndex === -1) {
|
||||
throw new Error(`The SEARCH block:\n${currentSearchContent.trimEnd()}\n...is malformatted.`)
|
||||
}
|
||||
|
||||
// Store this replacement
|
||||
replacements.push({
|
||||
start: searchMatchIndex,
|
||||
|
||||
@@ -6,6 +6,9 @@ import * as path from "path"
|
||||
import { FileContextTracker } from "./FileContextTracker"
|
||||
import * as diskModule from "@core/storage/disk"
|
||||
import type { TaskMetadata, FileMetadataEntry } from "./ContextTrackerTypes"
|
||||
import type { WebviewProviderCreator } from "@/hosts/host-providers"
|
||||
import * as hostProviders from "@hosts/host-providers"
|
||||
import { vscodeHostBridgeClient } from "@/hosts/vscode/client/host-grpc-client"
|
||||
|
||||
describe("FileContextTracker", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
@@ -37,7 +40,6 @@ describe("FileContextTracker", () => {
|
||||
}
|
||||
|
||||
// Use a function replacement instead of a direct stub
|
||||
const originalCreateFileSystemWatcher = vscode.workspace.createFileSystemWatcher
|
||||
vscode.workspace.createFileSystemWatcher = function () {
|
||||
return mockFileSystemWatcher
|
||||
}
|
||||
@@ -51,6 +53,7 @@ describe("FileContextTracker", () => {
|
||||
mockTaskMetadata = { files_in_context: [], model_usage: [] }
|
||||
getTaskMetadataStub = sandbox.stub(diskModule, "getTaskMetadata").resolves(mockTaskMetadata)
|
||||
saveTaskMetadataStub = sandbox.stub(diskModule, "saveTaskMetadata").resolves()
|
||||
hostProviders.initializeHostProviders(((_) => {}) as WebviewProviderCreator, vscodeHostBridgeClient)
|
||||
|
||||
// Create tracker instance
|
||||
taskId = "test-task-id"
|
||||
|
||||
@@ -5,6 +5,8 @@ import { getWorkspaceState, updateWorkspaceState } from "@core/storage/state"
|
||||
import { getGlobalState } from "@core/storage/state"
|
||||
import type { FileMetadataEntry } from "./ContextTrackerTypes"
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { getHostBridgeProvider } from "@/hosts/host-providers"
|
||||
import { getCwd } from "@/utils/path"
|
||||
|
||||
// This class is responsible for tracking file operations that may result in stale context.
|
||||
// If a user modifies a file outside of Cline, the context may become stale and need to be updated.
|
||||
@@ -34,17 +36,6 @@ export class FileContextTracker {
|
||||
this.taskId = taskId
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current working directory or returns undefined if it cannot be determined
|
||||
*/
|
||||
private getCwd(): string | undefined {
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||
if (!cwd) {
|
||||
console.info("No workspace folder available - cannot determine current working directory")
|
||||
}
|
||||
return cwd
|
||||
}
|
||||
|
||||
/**
|
||||
* File watchers are set up for each file that is tracked in the task metadata.
|
||||
*/
|
||||
@@ -54,8 +45,9 @@ export class FileContextTracker {
|
||||
return
|
||||
}
|
||||
|
||||
const cwd = this.getCwd()
|
||||
const cwd = await getCwd()
|
||||
if (!cwd) {
|
||||
console.info("No workspace folder available - cannot determine current working directory")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -85,8 +77,9 @@ export class FileContextTracker {
|
||||
*/
|
||||
async trackFileContext(filePath: string, operation: "read_tool" | "user_edited" | "cline_edited" | "file_mentioned") {
|
||||
try {
|
||||
const cwd = this.getCwd()
|
||||
const cwd = await getCwd()
|
||||
if (!cwd) {
|
||||
console.info("No workspace folder available - cannot determine current working directory")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -242,7 +235,9 @@ export class FileContextTracker {
|
||||
async storePendingFileContextWarning(files: string[]): Promise<void> {
|
||||
try {
|
||||
const key = `pendingFileContextWarning_${this.taskId}`
|
||||
await updateWorkspaceState(this.context, key, files)
|
||||
// NOTE: Using 'as any' because dynamic keys like pendingFileContextWarning_${taskId}
|
||||
// are legitimate workspace state keys but don't fit the strict LocalStateKey type system
|
||||
await updateWorkspaceState(this.context, key as any, files)
|
||||
} catch (error) {
|
||||
console.error("Error storing pending file context warning:", error)
|
||||
}
|
||||
@@ -254,7 +249,7 @@ export class FileContextTracker {
|
||||
async retrievePendingFileContextWarning(): Promise<string[] | undefined> {
|
||||
try {
|
||||
const key = `pendingFileContextWarning_${this.taskId}`
|
||||
const files = (await getWorkspaceState(this.context, key)) as string[]
|
||||
const files = (await getWorkspaceState(this.context, key as any)) as string[]
|
||||
return files
|
||||
} catch (error) {
|
||||
console.error("Error retrieving pending file context warning:", error)
|
||||
@@ -269,7 +264,7 @@ export class FileContextTracker {
|
||||
try {
|
||||
const files = await this.retrievePendingFileContextWarning()
|
||||
if (files) {
|
||||
await updateWorkspaceState(this.context, `pendingFileContextWarning_${this.taskId}`, undefined)
|
||||
await updateWorkspaceState(this.context, `pendingFileContextWarning_${this.taskId}` as any, undefined)
|
||||
return files
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -300,7 +295,7 @@ export class FileContextTracker {
|
||||
|
||||
if (orphanedPendingContextTasks.length > 0) {
|
||||
for (const key of orphanedPendingContextTasks) {
|
||||
await updateWorkspaceState(context, key, undefined)
|
||||
await updateWorkspaceState(context, key as any, undefined)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import * as vscode from "vscode"
|
||||
import crypto from "crypto"
|
||||
import { Controller } from "../index"
|
||||
import { storeSecret } from "../../storage/state"
|
||||
import { AuthService } from "@/services/auth/AuthService"
|
||||
import { EmptyRequest, String } from "../../../shared/proto/common"
|
||||
import { openExternal } from "@utils/env"
|
||||
|
||||
const authService = AuthService.getInstance()
|
||||
|
||||
/**
|
||||
* Handles the user clicking the login link in the UI.
|
||||
@@ -13,21 +14,5 @@ import { EmptyRequest, String } from "../../../shared/proto/common"
|
||||
* @returns The login URL as a string.
|
||||
*/
|
||||
export async function accountLoginClicked(controller: Controller, _: EmptyRequest): Promise<String> {
|
||||
// Generate nonce for state validation
|
||||
const nonce = crypto.randomBytes(32).toString("hex")
|
||||
await storeSecret(controller.context, "authNonce", nonce)
|
||||
|
||||
// Open browser for authentication with state param
|
||||
console.log("Login button clicked in account page")
|
||||
console.log("Opening auth page with state param")
|
||||
|
||||
const uriScheme = vscode.env.uriScheme
|
||||
|
||||
const authUrl = vscode.Uri.parse(
|
||||
`https://app.cline.bot/auth?state=${encodeURIComponent(nonce)}&callback_url=${encodeURIComponent(`${uriScheme || "vscode"}://saoudrizwan.claude-dev/auth`)}`,
|
||||
)
|
||||
await vscode.env.openExternal(authUrl)
|
||||
return String.create({
|
||||
value: authUrl.toString(),
|
||||
})
|
||||
return await authService.createAuthRequest()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { AuthService } from "@/services/auth/AuthService"
|
||||
import { Empty } from "../../../shared/proto/common"
|
||||
import type { EmptyRequest } from "../../../shared/proto/common"
|
||||
import type { Controller } from "../index"
|
||||
|
||||
const authService = AuthService.getInstance()
|
||||
/**
|
||||
* Handles the account logout action
|
||||
* @param controller The controller instance
|
||||
@@ -10,5 +12,6 @@ import type { Controller } from "../index"
|
||||
*/
|
||||
export async function accountLogoutClicked(controller: Controller, _request: EmptyRequest): Promise<Empty> {
|
||||
await controller.handleSignOut()
|
||||
await authService.handleDeauth()
|
||||
return Empty.create({})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AuthStateChangedRequest, AuthStateChanged } from "@shared/proto/account"
|
||||
import { AuthStateChangedRequest, AuthState } from "@shared/proto/account"
|
||||
import type { Controller } from "../index"
|
||||
import { updateGlobalState } from "../../storage/state"
|
||||
|
||||
@@ -9,13 +9,13 @@ import { updateGlobalState } from "../../storage/state"
|
||||
* @param request The auth state change request
|
||||
* @returns The updated user info
|
||||
*/
|
||||
export async function authStateChanged(controller: Controller, request: AuthStateChangedRequest): Promise<AuthStateChanged> {
|
||||
export async function authStateChanged(controller: Controller, request: AuthStateChangedRequest): Promise<AuthState> {
|
||||
try {
|
||||
// Store the user info directly in global state
|
||||
await updateGlobalState(controller.context, "userInfo", request.user)
|
||||
|
||||
// Return the same user info
|
||||
return AuthStateChanged.create({ user: request.user })
|
||||
return AuthState.create({ user: request.user })
|
||||
} catch (error) {
|
||||
console.error(`Failed to update auth state: ${error}`)
|
||||
throw error
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Controller } from "../index"
|
||||
import { GetOrganizationCreditsRequest, OrganizationCreditsData, OrganizationUsageTransaction } from "@shared/proto/account"
|
||||
|
||||
/**
|
||||
* Handles fetching all organization credits data (balance, usage, payments)
|
||||
* @param controller The controller instance
|
||||
* @param request Organization credits request
|
||||
* @returns Organization credits data response
|
||||
*/
|
||||
export async function getOrganizationCredits(
|
||||
controller: Controller,
|
||||
request: GetOrganizationCreditsRequest,
|
||||
): Promise<OrganizationCreditsData> {
|
||||
try {
|
||||
if (!controller.accountService) {
|
||||
throw new Error("Account service not available")
|
||||
}
|
||||
|
||||
// Call the individual RPC variants in parallel
|
||||
const [balanceData, usageTransactions] = await Promise.all([
|
||||
controller.accountService.fetchOrganizationCreditsRPC(request.organizationId),
|
||||
controller.accountService.fetchOrganizationUsageTransactionsRPC(request.organizationId),
|
||||
])
|
||||
|
||||
return OrganizationCreditsData.create({
|
||||
balance: balanceData ? { currentBalance: balanceData.balance / 100 } : { currentBalance: 0 },
|
||||
organizationId: balanceData?.organizationId || "",
|
||||
usageTransactions:
|
||||
usageTransactions?.map((tx) =>
|
||||
OrganizationUsageTransaction.create({
|
||||
aiInferenceProviderName: tx.aiInferenceProviderName,
|
||||
aiModelName: tx.aiModelName,
|
||||
aiModelTypeName: tx.aiModelTypeName,
|
||||
completionTokens: tx.completionTokens,
|
||||
costUsd: tx.costUsd,
|
||||
createdAt: tx.createdAt,
|
||||
creditsUsed: tx.creditsUsed,
|
||||
generationId: tx.generationId,
|
||||
organizationId: tx.organizationId,
|
||||
promptTokens: tx.promptTokens,
|
||||
totalTokens: tx.totalTokens,
|
||||
userId: tx.userId,
|
||||
}),
|
||||
) || [],
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch organization credits data: ${error}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
+4
-5
@@ -8,7 +8,7 @@ import { UserCreditsData } from "@shared/proto/account"
|
||||
* @param request Empty request
|
||||
* @returns User credits data response
|
||||
*/
|
||||
export async function fetchUserCreditsData(controller: Controller, request: EmptyRequest): Promise<UserCreditsData> {
|
||||
export async function getUserCredits(controller: Controller, request: EmptyRequest): Promise<UserCreditsData> {
|
||||
try {
|
||||
if (!controller.accountService) {
|
||||
throw new Error("Account service not available")
|
||||
@@ -21,11 +21,10 @@ export async function fetchUserCreditsData(controller: Controller, request: Empt
|
||||
controller.accountService.fetchPaymentTransactionsRPC(),
|
||||
])
|
||||
|
||||
// Since generated types match exactly, no conversion needed!
|
||||
return UserCreditsData.create({
|
||||
balance: balance ? { currentBalance: balance.currentBalance } : { currentBalance: 0 },
|
||||
usageTransactions: usageTransactions || [],
|
||||
paymentTransactions: paymentTransactions || [],
|
||||
balance: balance ? { currentBalance: balance.balance / 100 } : { currentBalance: 0 },
|
||||
usageTransactions: usageTransactions,
|
||||
paymentTransactions: paymentTransactions,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch user credits data: ${error}`)
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Controller } from "../index"
|
||||
import type { EmptyRequest } from "@shared/proto/common"
|
||||
import { UserOrganization, UserOrganizationsResponse } from "@shared/proto/account"
|
||||
|
||||
/**
|
||||
* Handles fetching all user credits data (balance, usage, payments)
|
||||
* @param controller The controller instance
|
||||
* @param request Empty request
|
||||
* @returns User credits data response
|
||||
*/
|
||||
export async function getUserOrganizations(controller: Controller, request: EmptyRequest): Promise<UserOrganizationsResponse> {
|
||||
try {
|
||||
if (!controller.accountService) {
|
||||
throw new Error("Account service not available")
|
||||
}
|
||||
|
||||
// Fetch user organizations from the account service
|
||||
const organizations = await controller.accountService.fetchUserOrganizationsRPC()
|
||||
|
||||
return UserOrganizationsResponse.create({
|
||||
organizations:
|
||||
organizations?.map((org) =>
|
||||
UserOrganization.create({
|
||||
active: org.active,
|
||||
memberId: org.memberId,
|
||||
name: org.name,
|
||||
organizationId: org.organizationId,
|
||||
roles: org.roles ? [...org.roles] : [],
|
||||
}),
|
||||
) || [],
|
||||
})
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Controller } from "../index"
|
||||
import { Empty } from "@shared/proto/common"
|
||||
import { UserOrganizationUpdateRequest } from "@shared/proto/account"
|
||||
|
||||
/**
|
||||
* Handles setting the user's active organization
|
||||
* @param controller The controller instance
|
||||
* @param request UserOrganization to set as active
|
||||
* @returns Empty response
|
||||
*/
|
||||
export async function setUserOrganization(controller: Controller, request: UserOrganizationUpdateRequest): Promise<Empty> {
|
||||
try {
|
||||
if (!controller.accountService) {
|
||||
throw new Error("Account service not available")
|
||||
}
|
||||
|
||||
// Switch to the specified organization using the account service
|
||||
await controller.accountService.switchAccount(request.organizationId)
|
||||
|
||||
return Empty.create({})
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { Controller } from "../index"
|
||||
import { EmptyRequest } from "../../../shared/proto/common"
|
||||
import { String as ProtoString } from "../../../shared/proto/common"
|
||||
import { StreamingResponseHandler, getRequestRegistry } from "../grpc-handler"
|
||||
|
||||
// Keep track of active authCallback subscriptions
|
||||
const activeAuthCallbackSubscriptions = new Set<StreamingResponseHandler>()
|
||||
|
||||
/**
|
||||
* Subscribe to authCallback events
|
||||
* @param controller The controller instance
|
||||
* @param request The empty request
|
||||
* @param responseStream The streaming response handler
|
||||
* @param requestId The ID of the request (passed by the gRPC handler)
|
||||
*/
|
||||
export async function subscribeToAuthCallback(
|
||||
controller: Controller,
|
||||
request: EmptyRequest,
|
||||
responseStream: StreamingResponseHandler,
|
||||
requestId?: string,
|
||||
): Promise<void> {
|
||||
// Add this subscription to the active subscriptions
|
||||
activeAuthCallbackSubscriptions.add(responseStream)
|
||||
|
||||
// Register cleanup when the connection is closed
|
||||
const cleanup = () => {
|
||||
activeAuthCallbackSubscriptions.delete(responseStream)
|
||||
}
|
||||
|
||||
// Register the cleanup function with the request registry if we have a requestId
|
||||
if (requestId) {
|
||||
getRequestRegistry().registerRequest(requestId, cleanup, { type: "authCallback_subscription" }, responseStream)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an authCallback event to all active subscribers
|
||||
* @param customToken The custom token for authentication
|
||||
*/
|
||||
export async function sendAuthCallbackEvent(customToken: string): Promise<void> {
|
||||
// Send the event to all active subscribers
|
||||
const promises = Array.from(activeAuthCallbackSubscriptions).map(async (responseStream) => {
|
||||
try {
|
||||
const event: ProtoString = {
|
||||
value: customToken,
|
||||
}
|
||||
await responseStream(
|
||||
event,
|
||||
false, // Not the last message
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Error sending authCallback event:", error)
|
||||
// Remove the subscription if there was an error
|
||||
activeAuthCallbackSubscriptions.delete(responseStream)
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(promises)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AuthService } from "../../../services/auth/AuthService"
|
||||
|
||||
const authService = AuthService.getInstance()
|
||||
export const subscribeToAuthStatusUpdate = authService.subscribeToAuthStatusUpdate.bind(authService)
|
||||
export const sendAuthStatusUpdateEvent = authService.sendAuthStatusUpdate.bind(authService)
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as vscode from "vscode"
|
||||
import { Controller } from ".."
|
||||
import { Empty, StringRequest } from "../../../shared/proto/common"
|
||||
import { writeTextToClipboard } from "@/utils/env"
|
||||
|
||||
/**
|
||||
* Copies text to the system clipboard
|
||||
@@ -11,7 +12,7 @@ import { Empty, StringRequest } from "../../../shared/proto/common"
|
||||
export async function copyToClipboard(controller: Controller, request: StringRequest): Promise<Empty> {
|
||||
try {
|
||||
if (request.value) {
|
||||
await vscode.env.clipboard.writeText(request.value)
|
||||
await writeTextToClipboard(request.value)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error copying to clipboard:", error)
|
||||
|
||||
@@ -6,8 +6,8 @@ import { createRuleFile as createRuleFileImpl } from "@core/context/instructions
|
||||
import * as vscode from "vscode"
|
||||
import * as path from "path"
|
||||
import { handleFileServiceRequest } from "./index"
|
||||
import { cwd } from "@core/task"
|
||||
import { refreshWorkflowToggles } from "@/core/context/instructions/user-instructions/workflows"
|
||||
import { getCwd, getDesktopDir } from "@/utils/path"
|
||||
|
||||
/**
|
||||
* Creates a rule file in either global or workspace rules directory
|
||||
@@ -32,6 +32,7 @@ export const createRuleFile: FileMethodHandler = async (controller: Controller,
|
||||
throw new Error("Missing or invalid parameters")
|
||||
}
|
||||
|
||||
const cwd = await getCwd(getDesktopDir())
|
||||
const { filePath, fileExists } = await createRuleFileImpl(request.isGlobal, request.filename, cwd, request.type)
|
||||
|
||||
if (!filePath) {
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { Controller } from ".."
|
||||
import { RuleFileRequest, RuleFile } from "@shared/proto/file"
|
||||
import { FileMethodHandler } from "./index"
|
||||
import { refreshClineRulesToggles } from "@core/context/instructions/user-instructions/cline-rules"
|
||||
import { deleteRuleFile as deleteRuleFileImpl } from "@core/context/instructions/user-instructions/rule-helpers"
|
||||
import { refreshExternalRulesToggles } from "@core/context/instructions/user-instructions/external-rules"
|
||||
import { refreshWorkflowToggles } from "@core/context/instructions/user-instructions/workflows"
|
||||
import * as vscode from "vscode"
|
||||
import { RuleFile, RuleFileRequest } from "@shared/proto/file"
|
||||
import * as path from "path"
|
||||
import { cwd } from "@core/task"
|
||||
import * as vscode from "vscode"
|
||||
import { Controller } from ".."
|
||||
import { FileMethodHandler } from "./index"
|
||||
|
||||
/**
|
||||
* Deletes a rule file from either global or workspace rules directory
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Controller } from ".."
|
||||
import { RelativePathsRequest, RelativePaths } from "@shared/proto/file"
|
||||
import { FileMethodHandler } from "./index"
|
||||
import * as vscode from "vscode"
|
||||
import { asRelativePath } from "@/utils/path"
|
||||
import { RelativePaths, RelativePathsRequest } from "@shared/proto/file"
|
||||
import * as path from "path"
|
||||
import { Metadata, StringRequest } from "@shared/proto/common"
|
||||
import { getHostBridgeProvider } from "@hosts/host-providers"
|
||||
import { URI } from "vscode-uri"
|
||||
import { Controller } from ".."
|
||||
import { FileMethodHandler } from "./index"
|
||||
import { isDirectory } from "@/utils/fs"
|
||||
|
||||
/**
|
||||
* Converts a list of URIs to workspace-relative paths
|
||||
@@ -13,48 +13,32 @@ import { getHostBridgeProvider } from "@hosts/host-providers"
|
||||
* @returns Response with resolved relative paths
|
||||
*/
|
||||
export const getRelativePaths: FileMethodHandler = async (
|
||||
controller: Controller,
|
||||
_controller: Controller,
|
||||
request: RelativePathsRequest,
|
||||
): Promise<RelativePaths> => {
|
||||
const resolvedPaths = await Promise.all(
|
||||
request.uris.map(async (uriString) => {
|
||||
try {
|
||||
// Use the host URI service client instead of directly using vscode.Uri.parse
|
||||
const parseResponse = await getHostBridgeProvider().uriServiceClient.parse(
|
||||
StringRequest.create({
|
||||
metadata: Metadata.create({}),
|
||||
value: uriString,
|
||||
}),
|
||||
)
|
||||
const fileUri = vscode.Uri.parse(`${parseResponse.scheme}://${parseResponse.authority}${parseResponse.path}`)
|
||||
console.log("[DEBUG] UriServiceClient.parse:", fileUri)
|
||||
const relativePathToGet = vscode.workspace.asRelativePath(fileUri, false)
|
||||
|
||||
// If the path is still absolute, it's outside the workspace
|
||||
if (path.isAbsolute(relativePathToGet)) {
|
||||
console.warn(`Dropped file ${relativePathToGet} is outside the workspace. Sending original path.`)
|
||||
return fileUri.fsPath.replace(/\\/g, "/")
|
||||
} else {
|
||||
let finalPath = "/" + relativePathToGet.replace(/\\/g, "/")
|
||||
try {
|
||||
const stat = await vscode.workspace.fs.stat(fileUri)
|
||||
if (stat.type === vscode.FileType.Directory) {
|
||||
finalPath += "/"
|
||||
}
|
||||
} catch (statError) {
|
||||
console.error(`Error stating file ${fileUri.fsPath}:`, statError)
|
||||
}
|
||||
return finalPath
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error calculating relative path for ${uriString}:`, error)
|
||||
return null
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
// Filter out any null values from errors
|
||||
const validPaths = resolvedPaths.filter((path): path is string => path !== null)
|
||||
|
||||
return RelativePaths.create({ paths: validPaths })
|
||||
const result = []
|
||||
for (const uriString of request.uris) {
|
||||
try {
|
||||
result.push(await getRelativePath(uriString))
|
||||
} catch (error) {
|
||||
console.error(`Error calculating relative path for ${uriString}:`, error)
|
||||
}
|
||||
}
|
||||
return RelativePaths.create({ paths: result })
|
||||
}
|
||||
|
||||
async function getRelativePath(uriString: string): Promise<string> {
|
||||
const filePath = URI.parse(uriString, true).fsPath
|
||||
const relativePath = await asRelativePath(filePath)
|
||||
|
||||
// If the path is still absolute, it's outside the workspace
|
||||
if (path.isAbsolute(relativePath)) {
|
||||
throw new Error(`Dropped file ${relativePath} is outside the workspace.`)
|
||||
}
|
||||
|
||||
let result = "/" + relativePath.replace(/\\/g, "/")
|
||||
if (await isDirectory(filePath)) {
|
||||
result += "/"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user