mirror of
https://github.com/cline/cline.git
synced 2026-09-05 05:02:27 +08:00
Compare commits
113 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7867ab11ea | |||
| 6359430392 | |||
| d31f03d3f9 | |||
| 763085a727 | |||
| 6c9adcef96 | |||
| 683b6d7f3e | |||
| a1e776d8d0 | |||
| 5809b79f18 | |||
| 1d0655157e | |||
| 7c9365fc56 | |||
| 793706ebfb | |||
| 7c2dfdb71e | |||
| 2e962aeeda | |||
| a5b85bc5cc | |||
| f7742664ca | |||
| 207b702054 | |||
| f1a2923c4e | |||
| 17e95deb6a | |||
| 76b29b54f6 | |||
| d0b84bbfa3 | |||
| e2bb10c1f2 | |||
| 1bc1e832a4 | |||
| 7802e29629 | |||
| c3f6d4c413 | |||
| 1b1cfd9457 | |||
| 025ead194b | |||
| 902a0f0cb7 | |||
| 7503be4ec9 | |||
| 907ce31cbf | |||
| 62c7bb36b5 | |||
| 350b6e8dbf | |||
| 7d40772d20 | |||
| 336d3104d2 | |||
| dd0c3b7e39 | |||
| 5300e9506b | |||
| f105507c87 | |||
| ac353e9725 | |||
| 9c8d2ffa62 | |||
| db250c5f9f | |||
| 6a1f64bc13 | |||
| 7f9c6f7e84 | |||
| ef51b8bbbd | |||
| ad83b2cdf5 | |||
| 1d4ec2ac65 | |||
| 2ee41b70c2 | |||
| ff2789a803 | |||
| 9b22057939 | |||
| 41e5e6236c | |||
| 7b01a6d595 | |||
| 8db8fb98a1 | |||
| 57db641f75 | |||
| eaee1940a5 | |||
| ef286c2427 | |||
| 3122150d5e | |||
| 3a0f656337 | |||
| 03186c385c | |||
| bee5c1343d | |||
| 3c27b63013 | |||
| e3f36e5d3c | |||
| 7edd8deb3c | |||
| 4d217920e2 | |||
| 905b7edfce | |||
| 074b55625a | |||
| 1ac9aa3742 | |||
| 7481fe8e4c | |||
| ee6ee3f10f | |||
| a0c9503229 | |||
| fcf55ce707 | |||
| b3f5e03285 | |||
| e36f3666f9 | |||
| badb98a2f3 | |||
| bc56387ca7 | |||
| 1bf6a6e661 | |||
| 0d56fb813d | |||
| 89f50f5d3f | |||
| 10e81e1f11 | |||
| 40aed16f6f | |||
| b482b5aa26 | |||
| 0a9542fd61 | |||
| 204f17ec08 | |||
| 4845845db8 | |||
| f1d4908e47 | |||
| e5b7b576ce | |||
| d556af22b8 | |||
| 9b4a8f3ace | |||
| 779f6b10c3 | |||
| 16284eb128 | |||
| 983fbd1027 | |||
| 74e04061f3 | |||
| e19abb90b7 | |||
| 6dde25f2b0 | |||
| 5dfe6615c5 | |||
| 8b9b46de4c | |||
| 6949d21569 | |||
| 9b2e6f62ec | |||
| 9b720e11a2 | |||
| b331f83985 | |||
| 24bc36ebfe | |||
| ee77e0ec9f | |||
| 4f8eb47850 | |||
| 953a9d2626 | |||
| ac2692a9c0 | |||
| 835e303ef7 | |||
| a3a0a930c2 | |||
| d073efe5cd | |||
| 9ce4abd2ec | |||
| 30f90056d6 | |||
| 09a6e258f7 | |||
| 4922c7694b | |||
| 8b7e8d1c16 | |||
| ce446f108d | |||
| 61d0e10a26 | |||
| e6ff8e8802 |
@@ -41,11 +41,11 @@ fi
|
||||
|
||||
# Install project dependencies
|
||||
echo "Installing dependencies..."
|
||||
npm run install:all
|
||||
bun run install:all
|
||||
|
||||
# Generate gRPC/protobuf types (required for TypeScript)
|
||||
echo "Generating proto types..."
|
||||
npm run protos
|
||||
bun run protos
|
||||
|
||||
echo ""
|
||||
echo "Session setup complete!"
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Bun (tooling) and Node (runtime)
|
||||
|
||||
This repo uses **bun** for package management and task running, and **Node** as
|
||||
the execution runtime. Both are correct at the same time; the distinction is the
|
||||
source of most confusion, so keep it straight before editing scripts, configs,
|
||||
docs, or comments.
|
||||
|
||||
## Use bun for tooling
|
||||
|
||||
- `bun install` (never `npm install` / `npm ci`)
|
||||
- `bun run <script>` (never `npm run <script>`)
|
||||
- `bunx <bin>` (never `npx <bin>`)
|
||||
- `bun <file>.ts` to run a TS entrypoint directly (no `ts-node` / `tsx`)
|
||||
- `bun esbuild.mjs` to drive the build (esbuild/vite are still the bundlers)
|
||||
- `bun run --parallel ...` for parallel tasks
|
||||
|
||||
The root `bun.lock` is the single lockfile for the whole workspace, including
|
||||
`apps/vscode`, `webview-ui`, and `testing-platform`. There are no per-package npm
|
||||
lockfiles.
|
||||
|
||||
## Node is the runtime — do NOT rewrite these to bun
|
||||
|
||||
The build product runs on Node: the VS Code extension host loads
|
||||
`dist/extension.js` as CommonJS under Node, and the standalone `cline-core` is a
|
||||
Node process. The following are Node runtime/ABI references and are correct as-is:
|
||||
|
||||
| Reference | Why it is Node |
|
||||
|-----------|----------------|
|
||||
| esbuild `platform: "node"` / `target: "node..."` | The bundle targets the Node runtime (extension host, standalone core). |
|
||||
| `TARGET_NODE_VERSION` (`scripts/package-standalone.mjs`) | Pins the Node ABI of the bundled standalone runtime (matches the JetBrains-packaged Node). |
|
||||
| `prebuild-install --target=<node version>` | Downloads native `.node` binaries for that Node ABI. |
|
||||
| `NODE_PATH=... node cline-core.js` | The standalone core is launched by Node, not bun. |
|
||||
| `node:` import specifiers (e.g. `node:fs`) | Node builtin module scheme; unrelated to tooling. |
|
||||
| `process.versions.node`, `engines.node`, `@types/node` | Runtime version probe / declared runtime / its types. |
|
||||
| `ELECTRON_RUN_AS_NODE` | VS Code/Electron runs the extension host as Node. |
|
||||
|
||||
When a file legitimately uses both bun and node (e.g. `package-standalone.mjs`
|
||||
does `bun install` but `prebuild-install --target=<node>`), the `node` token is
|
||||
the runtime/ABI target, not tooling. If unsure, leave it.
|
||||
|
||||
## Tests: bun vs the VS Code host
|
||||
|
||||
A test file's runner is decided by its import:
|
||||
|
||||
- **`import ... from "bun:test"`** → runs under `bun test` (the node-side unit
|
||||
suites + the SDK/model-catalog suites). `scripts/run-bun-unit-tests.ts`
|
||||
discovers these by the `bun:test` import and runs one isolated bun process per
|
||||
file. `build-tests.js` excludes them from the integration compile so the
|
||||
`bun:test` builtin never reaches Node.
|
||||
- **`import ... from "mocha"`** → runs under `@vscode/test-cli` in a real VS Code
|
||||
extension host (Node). These exercise the live `vscode` API and cannot run
|
||||
under bun.
|
||||
|
||||
So a file imports `bun:test` XOR `mocha`. Don't add `bun:test` to a test that
|
||||
needs the real extension host.
|
||||
@@ -0,0 +1,128 @@
|
||||
# Debug Harness
|
||||
|
||||
HTTP-controlled debugger for the VSCode extension at `src/dev/debug-harness/server.ts`.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Build extension first if needed (protos + esbuild):
|
||||
bun run protos && IS_DEV=true bun esbuild.mjs
|
||||
|
||||
# Launch (skip-build if already built):
|
||||
bun src/dev/debug-harness/server.ts --skip-build --auto-launch
|
||||
|
||||
# In another terminal:
|
||||
curl localhost:19229/api -d '{"method":"status"}'
|
||||
```
|
||||
|
||||
## Data Isolation
|
||||
|
||||
The debugee runs with `CLINE_DIR=~/.cline2` by default, separate from your real `~/.cline`.
|
||||
This prevents the debugee's logout from logging out the debugger, and vice versa.
|
||||
Override with `--cline-dir /tmp/test-dir`. Check with `status()` → `clineDir`.
|
||||
|
||||
## Browser Capture & OAuth
|
||||
|
||||
The debugee runs with `CLINE_CAPTURE_BROWSER=1`, which intercepts `openExternal()` in
|
||||
`src/utils/env.ts`. URLs are captured instead of opening a real browser:
|
||||
|
||||
- Logged to `$CLINE_DIR/data/debug-captured-urls.jsonl`
|
||||
- POSTed in real-time to `/captured-url` on the harness server
|
||||
- Queryable via `oauth.captured_urls`
|
||||
|
||||
### OAuth API
|
||||
|
||||
- **`oauth.captured_urls`** `{clear?}` — URLs the debugee tried to open
|
||||
- **`oauth.read_stored_token`** — Check auth token presence in secrets.json
|
||||
- **`oauth.simulate_callback`** `{path, code?, state?, provider?, token?}` — Build vscode:// callback URI
|
||||
- **`oauth.read_captured_urls_file`** — Read on-disk JSONL of captured URLs
|
||||
|
||||
### OAuth testing flow
|
||||
|
||||
For **Cline OAuth** (SDK local callback): The SDK starts a local HTTP server, the auth URL
|
||||
is captured. To complete: open the captured URL in a real browser (it redirects back to the
|
||||
SDK's callback server), OR extract the callback port and `curl http://127.0.0.1:PORT/callback?code=...`.
|
||||
|
||||
For **MCP/Provider OAuth** (vscode:// URI): The redirect goes to a vscode:// URI.
|
||||
`oauth.simulate_callback` only *builds* the URI — it does not deliver it, and the ESM
|
||||
extension host can't `require()` the handler. To actually deliver the callback, call the
|
||||
debug-only hook via `ext.evaluate` (with `awaitPromise: true`):
|
||||
`globalThis.__clineHandleUri("vscode://saoudrizwan.claude-dev/...?code=...&state=...")`.
|
||||
It runs the same `SharedUriHandler.handleUri` as VSCode's real URI handler and exists only
|
||||
when `CLINE_CAPTURE_BROWSER` is set (the harness always sets it; never ships in prod).
|
||||
For end-to-end MCP OAuth, get a real `code` from the local MCP OAuth test server
|
||||
(`bun run dev:mcp-oauth-test-server`).
|
||||
|
||||
## Navigating Views — Use Commands, Not Clicks
|
||||
|
||||
Don't try to find/click small sidebar icons. Use VSCode commands via command palette.
|
||||
Registered in `src/registry.ts`:
|
||||
|
||||
| Command | View |
|
||||
|---------|------|
|
||||
| `cline.accountButtonClicked` | Account / sign-in |
|
||||
| `cline.historyButtonClicked` | Task history |
|
||||
| `cline.settingsButtonClicked` | Settings |
|
||||
| `cline.mcpButtonClicked` | MCP servers |
|
||||
| `cline.plusButtonClicked` | New task (chat) |
|
||||
| `cline.worktreesButtonClicked` | Worktrees |
|
||||
|
||||
```bash
|
||||
curl localhost:19229/api -d '{"method":"ui.command_palette","params":{"command":"cline.accountButtonClicked"}}'
|
||||
```
|
||||
|
||||
## Key commands
|
||||
|
||||
All via `POST localhost:19229/api` with `{"method":"...", "params":{...}}`:
|
||||
|
||||
- **`launch`** / **`shutdown`** — lifecycle
|
||||
- **`ui.screenshot`** — screenshot to `/tmp/cline-debug/`; returns `{path}` — **use `read_file` on the path to examine, do NOT `open` the file** (Preview.app covers the VSCode window)
|
||||
- **`ui.open_sidebar`** — open the Cline sidebar
|
||||
- **`ext.set_breakpoint`** `{file, line, condition?}` — breakpoint by source file (sourcemap-resolved)
|
||||
- **`ext.evaluate`** `{expression, callFrameId?}` — eval in extension host
|
||||
- **`ext.resume`** / **`ext.step_over`** / **`ext.step_into`** — stepping
|
||||
- **`ext.call_stack`** — inspect when paused
|
||||
- **`web.evaluate`** `{expression}` — eval in webview
|
||||
- **`web.post_message`** `{message}` — send postMessage to extension host via exposed vsCodeApi
|
||||
- **`wait_for_pause`** `{timeout?}` — block until breakpoint hit
|
||||
- **`ui.locator`** `{role?, testId?, text?, frame?}` — Playwright locator (auto-retries on stale sidebar frame)
|
||||
- **`ui.react_input`** `{text, selector?, clear?, submit?}` — set React textarea value via `execCommand('insertText')`; works reliably across multiple tasks
|
||||
- **`ui.send_message`** `{text, images?, files?, responseType?}` — send chat message bypassing the textarea entirely (via gRPC postMessage)
|
||||
- **`ui.command_palette`** `{command}` — run VSCode command
|
||||
|
||||
## Typical Session
|
||||
|
||||
```bash
|
||||
# 1. Launch
|
||||
curl localhost:19229/api -d '{"method":"launch","params":{"skipBuild":true}}'
|
||||
|
||||
# 2. Open sidebar + dismiss overlays (ALWAYS do this first)
|
||||
curl localhost:19229/api -d '{"method":"ui.open_sidebar"}'
|
||||
curl localhost:19229/api -d '{"method":"web.evaluate","params":{"expression":"document.querySelectorAll(\".sr-only\").forEach(el => el.parentElement?.click())"}}'
|
||||
|
||||
# 3. Navigate to view
|
||||
curl localhost:19229/api -d '{"method":"ui.command_palette","params":{"command":"cline.accountButtonClicked"}}'
|
||||
|
||||
# 4. Check captured OAuth URLs if testing auth
|
||||
curl localhost:19229/api -d '{"method":"oauth.captured_urls"}'
|
||||
|
||||
# 5. Verify
|
||||
curl localhost:19229/api -d '{"method":"ui.screenshot"}'
|
||||
```
|
||||
|
||||
## Caveats
|
||||
|
||||
- **⚠️ Dismiss promotional overlays FIRST**: On fresh launches, full-screen promo overlays block the sidebar. **Dismiss immediately after `ui.open_sidebar`**, before any other interaction or screenshot. May need to run twice:
|
||||
```bash
|
||||
curl localhost:19229/api -d '{"method": "ui.open_sidebar"}'
|
||||
curl localhost:19229/api -d '{"method": "web.evaluate", "params": {"expression": "document.querySelectorAll(\".sr-only\").forEach(el => el.parentElement?.click())"}}'
|
||||
```
|
||||
- **Screenshots — don't open the file**: `ui.screenshot` and `ui.sidebar_screenshot` save PNGs to `/tmp/cline-debug/` and return the `{path}`. Use `read_file` on that path to examine screenshots. Running `open <path>` launches Preview.app on macOS which covers the VSCode window.
|
||||
- **Scripts count = 0 after launch**: CDP connects after extension host starts, so scripts parsed during startup aren't tracked. Breakpoints still work via sourcemap resolution.
|
||||
- **Port 9230**: Extension host inspector. If another VSCode instance uses this port, the harness will fail to connect. Kill other debug instances first.
|
||||
- **macOS only** for now (Playwright Electron launch behavior).
|
||||
- **Webview CDP**: `connect_webview` may fail depending on Electron version. `web.evaluate` still works via Playwright's `frame.evaluate()` fallback.
|
||||
- **Sourcemap paths**: esbuild outputs relative paths like `../src/extension.ts` in the sourcemap. The resolver handles this, but if a file isn't found, use `ext.source_files` to see exact paths.
|
||||
- **OAuth with fake codes**: Browser capture intercepts the URL but doesn't provide a valid auth code. For real OAuth testing, open the captured URL in a browser. For unit testing, mock the token exchange.
|
||||
|
||||
See `src/dev/debug-harness/README.md` for full API reference.
|
||||
+93
-90
@@ -13,11 +13,56 @@ This file is the secret sauce for working effectively in this codebase. It captu
|
||||
**What NOT to add:** Stuff you can figure out from reading a few files, obvious patterns, or standard practices. This file should be high-signal, not comprehensive.
|
||||
|
||||
## Miscellaneous
|
||||
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `npm run compile`, not `npm run build`).
|
||||
- The whole repo (including `apps/vscode`) uses **bun** for package management and task running. Emit `bun run X` / `bun install` / `bunx <bin>` / `bun file.ts`, never npm/npx. Node remains the *runtime* (VS Code's extension host and the standalone cline-core are Node), so Node-runtime tokens are legitimate and must not be "fixed" to bun — see @.clinerules/bun-and-node.md for the keep-list vs rewrite-list.
|
||||
- Avoid provider-specific string matching / hardcoded provider branches when fixing provider/config plumbing. Prefer provider metadata, shared catalog/defaults, explicit protocol/client capabilities, or centralized normalization utilities that apply by data shape rather than `providerId === "..."`. If a provider exception seems necessary, stop and explain why instead of adding ad-hoc string matching.
|
||||
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `bun run compile`, not `bun run build`).
|
||||
- When creating PRs, contributors should not create changelog-entry files. Maintainers handle release versioning and changelog curation during the release process.
|
||||
- When adding new feature flags, see this PR as a reference https://github.com/cline/cline/pull/7566
|
||||
- Additional instructions about making requests: @.clinerules/network.md
|
||||
|
||||
## Searching the Codebase — Avoiding Build Output
|
||||
|
||||
Several directories contain build output or generated code that produces
|
||||
noisy or unusable results with `search_files` / `grep`:
|
||||
|
||||
| Directory | What it is | Why it's a problem |
|
||||
|-----------|-----------|-------------------|
|
||||
| `out/` | esbuild bundle output | Mirrors `src/` structure as minified JS — every search gets duplicate hits on single-line files |
|
||||
| `dist/` | Packaged extension | Entire extension bundled into one minified `extension.js` (~1 long line) |
|
||||
| `dist-standalone/` | Standalone build output | Same minification issue |
|
||||
| `src/generated/` | Generated protobuf code | Auto-generated from `proto/`; not the source of truth |
|
||||
| `src/shared/proto/` | Generated proto type defs | Auto-generated from `proto/`; not the source of truth |
|
||||
| `node_modules/` | Dependencies | Huge, not project source |
|
||||
|
||||
### How to skip build output
|
||||
|
||||
**`search_files`** — Point at `src/` (not the project root) and use `file_pattern`:
|
||||
```
|
||||
search_files(path="src/core", regex="myFunction", file_pattern="*.ts")
|
||||
```
|
||||
The `file_pattern` parameter is the most effective filter — e.g. `"*.ts"`,
|
||||
`"*.tsx"`, `"*.proto"`.
|
||||
|
||||
**`grep` directly** — Exclude build dirs and restrict to source extensions:
|
||||
```bash
|
||||
grep -rn "myFunction" src/ --include="*.ts" --exclude-dir={out,dist,node_modules,generated}
|
||||
```
|
||||
|
||||
### When you must search minified files
|
||||
|
||||
Sometimes you need to verify what got bundled (e.g., checking if a change
|
||||
made it into the build). Minified files are typically one long line, so
|
||||
normal `grep` shows the entire file as context. Use these approaches:
|
||||
|
||||
- **`grep -oP`** to extract just the match with limited surrounding context:
|
||||
```bash
|
||||
grep -oP '.{0,40}myFunction.{0,40}' dist/extension.js
|
||||
```
|
||||
- **`read_file`** on files in `out/src/` — these have source maps and are
|
||||
more readable than `dist/extension.js` (which is the fully bundled output).
|
||||
- **Source maps** — `out/src/*.js.map` and `dist/extension.js.map` can be
|
||||
used to trace minified output back to original source locations.
|
||||
|
||||
## gRPC/Protobuf Communication
|
||||
The extension and webview communicate via gRPC-like protocol over VS Code message passing.
|
||||
|
||||
@@ -28,7 +73,7 @@ The extension and webview communicate via gRPC-like protocol over VS Code messag
|
||||
- Naming: Services `PascalCaseService`, RPCs `camelCase`, Messages `PascalCase`
|
||||
- For streaming responses, use `stream` keyword (see `subscribeToAuthCallback` in `account.proto`)
|
||||
|
||||
**Run `npm run protos`** after any proto changes—generates types in:
|
||||
**Run `bun run protos`** after any proto changes—generates types in:
|
||||
- `src/shared/proto/` - Shared type definitions
|
||||
- `src/generated/grpc-js/` - Service implementations
|
||||
- `src/generated/nice-grpc/` - Promise-based clients
|
||||
@@ -48,93 +93,6 @@ The extension and webview communicate via gRPC-like protocol over VS Code messag
|
||||
- `src/core/controller/task/explainChanges.ts` - Handler implementation
|
||||
- `webview-ui/src/components/chat/ChatRow.tsx` - UI rendering
|
||||
|
||||
## Adding a New API Provider
|
||||
When adding a new provider (e.g., "openai-codex"), you must update the proto conversion layer in THREE places or the provider will silently reset to Anthropic:
|
||||
|
||||
1. `proto/cline/models.proto` - Add to the `ApiProvider` enum (e.g., `OPENAI_CODEX = 40;`)
|
||||
2. `convertApiProviderToProto()` in `src/shared/proto-conversions/models/api-configuration-conversion.ts` - Add case mapping string to proto enum
|
||||
3. `convertProtoToApiProvider()` in the same file - Add case mapping proto enum back to string
|
||||
|
||||
**Why this matters:** Without these, the provider string hits the `default` case and returns `ANTHROPIC`. The webview, provider list, and handler all work fine, but the state silently resets when it round-trips through proto serialization. No error is thrown.
|
||||
|
||||
**Other files to update when adding a provider:**
|
||||
- `src/shared/api.ts` - Add to `ApiProvider` union type, define models
|
||||
- `src/shared/providers/providers.json` - Add to provider list for dropdown
|
||||
- `src/core/api/index.ts` - Register handler in `createHandlerForProvider()`
|
||||
- `webview-ui/src/components/settings/utils/providerUtils.ts` - Add cases in `getModelsForProvider()` and `normalizeApiConfiguration()`
|
||||
- `webview-ui/src/utils/validate.ts` - Add validation case
|
||||
- `webview-ui/src/components/settings/ApiOptions.tsx` - Render provider component
|
||||
|
||||
## Responses API Providers (OpenAI Codex, OpenAI Native)
|
||||
Providers using OpenAI's Responses API require native tool calling. XML tools don't work with the Responses API.
|
||||
|
||||
**Symptoms of broken native tool calling:**
|
||||
- Tools get called multiple times (e.g., `ask_followup_question` asks the same question twice)
|
||||
- Tool arguments get duplicated or malformed
|
||||
- The model responds but tools aren't recognized
|
||||
|
||||
**Root causes to check:**
|
||||
1. **Provider missing from `isNextGenModelProvider()`** in `src/utils/model-utils.ts`. The native variant matchers (e.g., `native-gpt-5/config.ts`) call this function. If your provider isn't in the list, the matcher returns false and falls back to XML tools.
|
||||
|
||||
2. **Model missing `apiFormat: ApiFormat.OPENAI_RESPONSES`** in its model info (`src/shared/api.ts`). This property signals that the model requires native tool calling. The task runner in `src/core/task/index.ts` checks this and forces `enableNativeToolCalls: true` regardless of user settings.
|
||||
|
||||
**When adding a new Responses API provider:**
|
||||
1. Add provider to `isNextGenModelProvider()` list in `src/utils/model-utils.ts`
|
||||
2. Set `apiFormat: ApiFormat.OPENAI_RESPONSES` on all models that use the Responses API
|
||||
3. The variant matcher and task runner will handle the rest automatically
|
||||
|
||||
## Adding Tools to System Prompt
|
||||
This is tricky—multiple prompt variants and configs. **Always search for existing similar tools first and follow their pattern.** Look at the full chain from prompt definition → variant configs → handler → UI before implementing.
|
||||
|
||||
1. **Add to `ClineDefaultTool` enum** in `src/shared/tools.ts`
|
||||
2. **Tool definition** in `src/core/prompts/system-prompt/tools/` (create file like `generate_explanation.ts`)
|
||||
- Define variants for each `ModelFamily` (generic, next-gen, xs, etc.)
|
||||
- Export variants array (e.g., `export const my_tool_variants = [GENERIC, NATIVE_NEXT_GEN, XS]`)
|
||||
- **Fallback behavior**: If a variant isn't defined for a model family, `ClineToolSet.getToolByNameWithFallback()` automatically falls back to GENERIC. So you only need to export `[GENERIC]` unless the tool needs model-specific behavior.
|
||||
3. **Register in `src/core/prompts/system-prompt/tools/init.ts`** - Import and spread into `allToolVariants`
|
||||
4. **Add to variant configs** - Each model family has its own config in `src/core/prompts/system-prompt/variants/*/config.ts`. Add your tool's enum to the `.tools()` list:
|
||||
- `generic/config.ts`, `next-gen/config.ts`, `gpt-5/config.ts`, `native-gpt-5/config.ts`, `native-gpt-5-1/config.ts`, `native-next-gen/config.ts`, `gemini-3/config.ts`, `glm/config.ts`, `hermes/config.ts`, `xs/config.ts`
|
||||
- **Important**: If you add to a variant's config, make sure the tool spec exports a variant for that ModelFamily (or relies on GENERIC fallback)
|
||||
5. **Create handler** in `src/core/task/tools/handlers/`
|
||||
6. **Wire up in `ToolExecutor.ts`** if needed for execution flow
|
||||
7. **Add to tool parsing** in `src/core/assistant-message/index.ts` if needed
|
||||
8. **If tool has UI feedback**: add `ClineSay` enum in proto, update `src/shared/ExtensionMessage.ts`, update `src/shared/proto-conversions/cline-message.ts`, update `webview-ui/src/components/chat/ChatRow.tsx`
|
||||
|
||||
## Modifying System Prompt
|
||||
**Read these first:** `src/core/prompts/system-prompt/README.md`, `tools/README.md`, `__tests__/README.md`
|
||||
|
||||
System prompt is modular: **components** (reusable sections) + **variants** (model-specific configs) + **templates** (with `{{PLACEHOLDER}}` resolution).
|
||||
|
||||
**Key directories:**
|
||||
- `components/` - Shared sections: `rules.ts`, `capabilities.ts`, `editing_files.ts`, etc.
|
||||
- `variants/` - Model-specific: `generic/`, `next-gen/`, `xs/`, `gpt-5/`, `gemini-3/`, `hermes/`, `glm/`, etc.
|
||||
- `templates/` - Template engine and placeholder definitions
|
||||
|
||||
**Variant tiers (ask user which to modify):**
|
||||
- **Next-gen** (Claude 4, GPT-5, Gemini 2.5): `next-gen/`, `native-next-gen/`, `native-gpt-5/`, `native-gpt-5-1/`, `gemini-3/`, `gpt-5/`
|
||||
- **Standard** (default fallback): `generic/`
|
||||
- **Local/small models**: `xs/`, `hermes/`, `glm/`
|
||||
|
||||
**How overrides work:** Variants can override components via `componentOverrides` in their `config.ts`, or provide a custom template in `template.ts` (e.g., `next-gen/template.ts` exports `rules_template`). If no override, the shared component from `components/` is used.
|
||||
|
||||
**Example: Adding a rule to RULES section**
|
||||
1. Check if variant overrides rules: look for `rules_template` in `variants/*/template.ts` or `componentOverrides.RULES` in `config.ts`
|
||||
2. If shared: modify `components/rules.ts`
|
||||
3. If overridden: modify that variant's template
|
||||
4. XS variant is special—has heavily condensed inline content in `template.ts`
|
||||
|
||||
**After any changes, regenerate snapshots:**
|
||||
```bash
|
||||
UPDATE_SNAPSHOTS=true npm run test:unit
|
||||
```
|
||||
Snapshots live in `__tests__/__snapshots__/`. Tests validate across model families and context variations (browser, MCP, focus chain).
|
||||
|
||||
## Modifying Default Slash Commands
|
||||
Three places need updates:
|
||||
- `src/core/slash-commands/index.ts` - Command definitions
|
||||
- `src/core/prompts/commands.ts` - System prompt integration
|
||||
- `webview-ui/src/utils/slash-commands.ts` - Webview autocomplete
|
||||
|
||||
## Adding New Global State Keys
|
||||
Adding a new key to global state requires updates in multiple places. Missing any step causes silent failures.
|
||||
|
||||
@@ -151,7 +109,7 @@ Settings plumbing gotcha: if a key is user-toggleable from settings, wire both c
|
||||
Missing one path causes a toggle to appear to change in one surface while the backend state stays unchanged.
|
||||
|
||||
Webview toggle gotcha: settings changes must also round-trip back in state payloads.
|
||||
- Add the field to `UpdateSettingsRequest` in `proto/cline/state.proto` (for webview update requests), then run `npm run protos`
|
||||
- Add the field to `UpdateSettingsRequest` in `proto/cline/state.proto` (for webview update requests), then run `bun run protos`
|
||||
- Include the key in `Controller.getStateToPostToWebview()` (`src/core/controller/index.ts`)
|
||||
- Ensure `ExtensionState` and webview defaults include the key (`src/shared/ExtensionMessage.ts`, `webview-ui/src/context/ExtensionStateContext.tsx`)
|
||||
If this round-trip wiring is missing, the backend value can update but the toggle in webview appears stuck or reverts.
|
||||
@@ -199,3 +157,48 @@ const isGenerating = explanationInfo.status === "generating" && !wasCancelled
|
||||
**See also:** `BrowserSessionRow.tsx` uses similar pattern with `isLastApiReqInterrupted` and `isLastMessageResume`.
|
||||
|
||||
**Backend side:** When streaming is cancelled, clean up properly (close tabs, clear comments, etc.) by checking `taskState.abort` after the streaming function returns.
|
||||
|
||||
## Debug Harness: clear inherited VSCode/Electron env vars before launching
|
||||
|
||||
The debug harness (`apps/vscode/src/dev/debug-harness/server.ts`) launches a child
|
||||
VSCode via Playwright's `_electron.launch({ env: { ...process.env, ... } })`. If you
|
||||
run the harness from a process that was itself spawned by VSCode (e.g. the Cline
|
||||
extension host, an integrated terminal, or an agent running inside VSCode), the
|
||||
parent's VSCode/Electron env vars leak into the child and break the launch.
|
||||
|
||||
The fatal one is **`ELECTRON_RUN_AS_NODE=1`**: it makes the child VSCode binary run
|
||||
as plain Node, so it rejects every VSCode CLI flag. Symptom:
|
||||
|
||||
```
|
||||
.../Visual Studio Code.app/Contents/MacOS/Code: bad option: --extensionDevelopmentPath=...
|
||||
Error: Process failed to launch! (Playwright _electron.launch)
|
||||
```
|
||||
|
||||
This is NOT the macOS Playwright flakiness mentioned in the harness README — it's
|
||||
env inheritance. Fix: strip the inherited vars before starting the harness:
|
||||
|
||||
```bash
|
||||
env -u ELECTRON_RUN_AS_NODE -u ELECTRON_NO_ATTACH_CONSOLE \
|
||||
-u VSCODE_CLI -u VSCODE_CODE_CACHE_PATH -u VSCODE_CRASH_REPORTER_PROCESS_TYPE \
|
||||
-u VSCODE_CWD -u VSCODE_ESM_ENTRYPOINT -u VSCODE_HANDLES_UNCAUGHT_ERRORS \
|
||||
-u VSCODE_IPC_HOOK -u VSCODE_NLS_CONFIG -u VSCODE_PID -u VSCODE_L10N_BUNDLE_LOCATION \
|
||||
bun src/dev/debug-harness/server.ts --auto-launch --skip-build
|
||||
```
|
||||
|
||||
Check your own env with `env | grep -iE 'electron|vscode_'` first; `ELECTRON_RUN_AS_NODE=1`
|
||||
present means you must scrub before launching.
|
||||
|
||||
Other harness notes confirmed in practice:
|
||||
- The extension host is **ESM** (`VSCODE_ESM_ENTRYPOINT`), so `ext.evaluate` has no
|
||||
`require` and module-internal functions aren't reachable as globals. To inspect
|
||||
internal builders (e.g. `buildBedrockProviderConfig`), set a breakpoint with
|
||||
`ext.set_breakpoint` and read locals via `ext.evaluate` with the paused `callFrameId`
|
||||
— don't try to `require()` the bundle.
|
||||
- `web.evaluate` wraps the expression as a single returned expression; multi-statement
|
||||
snippets must be an IIFE `(() => { ...; return x; })()`, otherwise you get
|
||||
`SyntaxError: Unexpected token ';'`.
|
||||
- Webview settings inputs are `vscode-text-field` web components with debounced React
|
||||
onChange. Setting `.value` + dispatching events via `web.evaluate` is unreliable for
|
||||
some fields; focus the inner shadow `input` then use real keystrokes (`ui.type` +
|
||||
`ui.press Tab`, or click the dropdown option) to make the value persist.
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ Here, we use the common `StringRequest` and `KeyValuePair` types.
|
||||
|
||||
After editing a `.proto` file, regenerate the TypeScript code. From the project root, run:
|
||||
```bash
|
||||
npm run protos
|
||||
bun 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.
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# SDK Adapter
|
||||
|
||||
The VSCode extension runs on the Cline SDK (`@cline/core`, `@cline/llms`,
|
||||
`@cline/shared`) through an adapter layer in `apps/vscode/src/sdk/`. The
|
||||
webview still talks gRPC; the adapter translates between gRPC handlers and SDK
|
||||
calls. See `apps/vscode/src/dev/debug-harness/README.md` for the debug harness.
|
||||
|
||||
## Conventions
|
||||
|
||||
1. **Look up SDK APIs, don't guess.** Use `kb_search(name="sdk", query="...")`
|
||||
before implementing against an SDK surface.
|
||||
2. **Reference the pre-SDK implementation when replacing a module.** Add a
|
||||
`// Replaces classic src/core/... (see origin/main)` header and use
|
||||
`kb_search(name="cline", commit="origin/main")` or
|
||||
`git show origin/main:path` to consult the prior implementation.
|
||||
3. **Single entry point.** There is one codepath — the SDK adapter. No
|
||||
`CLINE_SDK` env flag.
|
||||
4. **Use `{appBaseUrl}`**, never hardcode `app.cline.bot`.
|
||||
5. **Avoid `as` casts.** Use explicit conversion functions with tests. The
|
||||
branded types in `apps/vscode/src/sdk/model-catalog/contracts.ts` exist so
|
||||
casts are unnecessary outside parse/compute boundaries.
|
||||
|
||||
## Debug harness
|
||||
|
||||
- **Dismiss the Kanban/promo overlay** before any debug harness interaction.
|
||||
- **Use the command palette** to navigate tabs in the debug harness.
|
||||
@@ -91,7 +91,7 @@ On the main branch, create a commit that updates:
|
||||
|
||||
3. No changelog-entry file cleanup is needed. Contributors do not create changelog-entry files in this repo.
|
||||
|
||||
**Skip running `npm run install:all`** - release automation handles lockfile consistency as needed.
|
||||
**No dependency install is needed.** A CHANGELOG + `version` bump does not change any dependency, and `bun.lock` does not pin workspace-package versions, so the lockfile stays consistent. The publish workflow runs `bun install --frozen-lockfile`, which would *fail* on an out-of-sync lock — so only run `bun install` here if you actually change dependencies (then commit the updated `bun.lock`).
|
||||
|
||||
Commit with message format: `v{VERSION} Release Notes (hotfix)`
|
||||
|
||||
|
||||
@@ -9,14 +9,14 @@ This is a VS Code extension. Read `.clinerules/general.md` for tribal knowledge
|
||||
- **MCP**: `src/services/mcp/McpHub.ts`.
|
||||
|
||||
## Build & Test (Critical — non-obvious commands)
|
||||
- **Build**: `npm run compile` — NOT `npm run build`.
|
||||
- **Watch**: `npm run watch` (extension + webview).
|
||||
- **Protos**: `npm run protos` — run **immediately** after any `.proto` change. Generates into `src/shared/proto/`, `src/generated/`.
|
||||
- **Tests**: `npm run test:unit`. After prompt/tool changes: `UPDATE_SNAPSHOTS=true npm run test:unit`.
|
||||
- **Build**: `bun run compile` — NOT `bun run build`.
|
||||
- **Watch**: `bun run watch` (extension + webview).
|
||||
- **Protos**: `bun run protos` — run **immediately** after any `.proto` change. Generates into `src/shared/proto/`, `src/generated/`.
|
||||
- **Tests**: `bun run test:unit`. After prompt/tool changes: `UPDATE_SNAPSHOTS=true bun run test:unit`.
|
||||
|
||||
## Protobuf RPC Workflow (4 steps)
|
||||
1. **Define** in `proto/cline/*.proto`. Naming: `PascalCaseService`, `camelCase` RPCs, `PascalCase` Messages. Use `common.proto` shared types for simple data.
|
||||
2. **Generate**: `npm run protos`.
|
||||
2. **Generate**: `bun run protos`.
|
||||
3. **Backend handler**: `src/core/controller/<domain>/`.
|
||||
4. **Frontend call**: `UiServiceClient.myMethod(Request.create({...}))`.
|
||||
- Adding enums (e.g. `ClineSay`) → also update `src/shared/proto-conversions/cline-message.ts`.
|
||||
@@ -38,7 +38,7 @@ For Responses API providers: add to `isNextGenModelProvider()` in `src/utils/mod
|
||||
4. Whitelist in `src/core/prompts/system-prompt/variants/*/config.ts` for each model family.
|
||||
5. Handler in `src/core/task/tools/handlers/`, wire in `ToolExecutor.ts`.
|
||||
6. If tool has UI: add `ClineSay` enum in proto → `ExtensionMessage.ts` → `cline-message.ts` → `ChatRow.tsx`.
|
||||
7. Regenerate snapshots: `UPDATE_SNAPSHOTS=true npm run test:unit`.
|
||||
7. Regenerate snapshots: `UPDATE_SNAPSHOTS=true bun run test:unit`.
|
||||
|
||||
## Modifying System Prompt
|
||||
Modular: `components/` (shared) + `variants/` (model-specific) + `templates/` (`{{PLACEHOLDER}}`). Variants override components via `componentOverrides` in `config.ts` or custom `template.ts`. XS variant is heavily condensed inline. Always regenerate snapshots after changes.
|
||||
|
||||
@@ -59,7 +59,7 @@ We're not looking for exhaustive documentation - just evidence that you've thoug
|
||||
<!-- Put an 'x' in all boxes that apply -->
|
||||
|
||||
- [ ] Changes are limited to a single feature, bugfix or chore (split larger changes into separate PRs)
|
||||
- [ ] Tests are passing (`npm test`) and code is formatted and linted (`npm run format && npm run lint`)
|
||||
- [ ] Tests are passing (`bun test`) and code is formatted and linted (`bun run format && bun run lint`)
|
||||
- [ ] I have reviewed [contributor guidelines](https://github.com/cline/cline/blob/main/CONTRIBUTING.md)
|
||||
|
||||
### Screenshots
|
||||
|
||||
@@ -31,6 +31,9 @@ jobs:
|
||||
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
|
||||
runs-on: ubuntu-latest
|
||||
environment: PublishNightly
|
||||
# The VS Code extension's package.json and lockfiles live under apps/vscode/
|
||||
# (the repo root has no package.json). Mirror ext-vscode-test.yml so install
|
||||
# and publish steps run in the correct workspace.
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
@@ -50,21 +53,47 @@ jobs:
|
||||
echo "Building sha: $GITHUB_SHA"
|
||||
git --no-pager log -1 --oneline
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
# Node is still REQUIRED in the publish job (not just for install): the publish
|
||||
# scripts run as `node ./scripts/publish-*.mjs` and shell out to `npx ovsx`.
|
||||
# setup-bun does not provide a Node runtime, so keep setup-node here.
|
||||
# Pinned to Node 22 because newer LTS (Node 24 / npm 11) can make vsce's
|
||||
# `npm list` dependency detection fail with ELSPROBLEMS during packaging.
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
# Keep publish environment aligned with test workflow/tooling lockfile expectations.
|
||||
# Newer LTS (Node 24 / npm 11) can make `npm list` fail with ELSPROBLEMS during vsce packaging.
|
||||
node-version: 22
|
||||
|
||||
- name: Install extension dependencies
|
||||
# Single root install resolves the whole bun workspace at once (replaces the
|
||||
# per-package `npm ci` steps for apps/vscode + webview-ui).
|
||||
- name: Install workspace dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci --include=optional
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
# @cline/* are local workspace symlinks to source packages; build dist/ before
|
||||
# packaging/publishing the extension.
|
||||
- name: Build SDK packages
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci --include=optional
|
||||
run: bun run build:sdk
|
||||
|
||||
- name: Assert better-sqlite3 native binary present
|
||||
run: |
|
||||
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
if [ ! -f "$NODE_FILE" ]; then
|
||||
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
|
||||
echo "(bun trustedDependencies postinstall likely did not run)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found better-sqlite3 native binary: $NODE_FILE"
|
||||
|
||||
# vsce is now a workspace devDependency (on node_modules/.bin), but ovsx is not
|
||||
# vendored and the publish script invokes it via `npx ovsx`, so install ovsx
|
||||
# globally here (npm is available via setup-node). vsce is installed globally
|
||||
# too to preserve the script's existing PATH expectations.
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
@@ -82,7 +111,9 @@ jobs:
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
run: npm run publish:marketplace:nightly
|
||||
# The script itself runs under `node ./scripts/publish-nightly.mjs`; bun run
|
||||
# just launches it. Node + npm (for `npx ovsx`) are provided by setup-node above.
|
||||
run: bun run publish:marketplace:nightly
|
||||
|
||||
- name: Tag published commit
|
||||
working-directory: ${{ github.workspace }}
|
||||
|
||||
@@ -109,19 +109,48 @@ jobs:
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
echo "resolved_sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
# Node is still REQUIRED in the publish job (not just for install): the
|
||||
# publish scripts run as `node scripts/publish-*.mjs`, the version step uses
|
||||
# `node -p`, and `npx ovsx` needs npm. setup-bun does not provide a Node
|
||||
# runtime, so keep setup-node. Pinned to Node 22 because newer LTS
|
||||
# (Node 24 / npm 11) can make vsce's `npm list` detection fail with
|
||||
# ELSPROBLEMS during packaging.
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install extension dependencies
|
||||
# Single root install resolves the whole bun workspace at once (replaces the
|
||||
# per-package `npm install` steps for apps/vscode + webview-ui).
|
||||
- name: Install workspace dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode install --include=optional
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
# @cline/* are local workspace symlinks to source packages; build dist/ before
|
||||
# packaging/publishing the extension.
|
||||
- name: Build SDK packages
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui install --include=optional
|
||||
run: bun run build:sdk
|
||||
|
||||
- name: Assert better-sqlite3 native binary present
|
||||
run: |
|
||||
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
if [ ! -f "$NODE_FILE" ]; then
|
||||
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
|
||||
echo "(bun trustedDependencies postinstall likely did not run)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found better-sqlite3 native binary: $NODE_FILE"
|
||||
|
||||
# vsce is a workspace devDependency (on node_modules/.bin), but ovsx is not
|
||||
# vendored and the publish script invokes it via `npx ovsx`, so install ovsx
|
||||
# globally (npm is available via setup-node). vsce is installed globally too
|
||||
# to preserve the script's existing PATH expectations.
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
@@ -164,14 +193,20 @@ jobs:
|
||||
node scripts/marketplace-readme.mjs swap-in
|
||||
trap 'node scripts/marketplace-readme.mjs restore' EXIT
|
||||
|
||||
# Required to generate the .vsix
|
||||
vsce package --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
|
||||
# Required to generate the .vsix. --no-dependencies: the extension
|
||||
# is fully esbuild-bundled, and under the bun workspace the @cline/*
|
||||
# deps are symlinks pointing outside the package, so without this vsce
|
||||
# would walk them and pull the whole monorepo into the .vsix.
|
||||
vsce package --no-dependencies --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
|
||||
|
||||
# These scripts run under `node scripts/publish-marketplace.mjs`;
|
||||
# bun run just launches them. Node + npm (for `npx ovsx`) come from
|
||||
# setup-node above.
|
||||
if [ "$RELEASE_TYPE" = "pre-release" ]; then
|
||||
npm run publish:marketplace:prerelease
|
||||
bun run publish:marketplace:prerelease
|
||||
echo "Successfully published pre-release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
else
|
||||
npm run publish:marketplace
|
||||
bun run publish:marketplace
|
||||
echo "Successfully published release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
fi
|
||||
|
||||
|
||||
@@ -45,12 +45,16 @@ jobs:
|
||||
- 'apps/vscode/assets/**'
|
||||
- 'apps/vscode/walkthrough/**'
|
||||
- 'apps/vscode/package.json'
|
||||
- 'apps/vscode/package-lock.json'
|
||||
- 'apps/vscode/webview-ui/package.json'
|
||||
# Root bun lockfile is authoritative for the whole workspace (incl. apps/vscode).
|
||||
- 'bun.lock'
|
||||
# SDK source packages are local workspace symlinks (@cline/*), so SDK changes affect the build.
|
||||
- 'sdk/packages/**'
|
||||
- 'apps/vscode/buf.yaml'
|
||||
- 'apps/vscode/tsconfig*.json'
|
||||
- 'apps/vscode/biome.jsonc'
|
||||
- 'apps/vscode/esbuild.mjs'
|
||||
- 'apps/vscode/.mocharc.json'
|
||||
- 'apps/vscode/bunfig.toml'
|
||||
- 'apps/vscode/.vscode-test.mjs'
|
||||
- 'apps/vscode/.vscodeignore'
|
||||
- 'apps/vscode/playwright*.ts'
|
||||
@@ -84,26 +88,20 @@ jobs:
|
||||
working-directory: apps/vscode
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
node-version: 22
|
||||
bun-version: 1.3.14
|
||||
|
||||
# Cache root dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache root dependencies
|
||||
# Cache Bun's global install cache - keyed on the authoritative root bun.lock.
|
||||
- name: Cache Bun install cache
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
id: bun-cache
|
||||
with:
|
||||
path: apps/vscode/node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('apps/vscode/package-lock.json') }}
|
||||
|
||||
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache webview-ui dependencies
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
with:
|
||||
path: apps/vscode/webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('apps/vscode/webview-ui/package-lock.json') }}
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
# Cache VS Code installation
|
||||
- name: Cache VS Code
|
||||
@@ -124,22 +122,41 @@ jobs:
|
||||
~/.cache/ms-playwright
|
||||
~/Library/Caches/ms-playwright
|
||||
~/AppData/Local/ms-playwright
|
||||
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('apps/vscode/package-lock.json') }}
|
||||
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
playwright-browsers-${{ runner.os }}-
|
||||
|
||||
- name: Install extension dependencies
|
||||
# Single root install resolves the entire bun workspace at once (replaces
|
||||
# the per-package `npm ci` steps for apps/vscode + webview-ui).
|
||||
- name: Install workspace dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
# @cline/* are local workspace symlinks to source packages; build dist/
|
||||
# before building/packaging the extension for E2E.
|
||||
- name: Build SDK packages
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
run: bun run build:sdk
|
||||
|
||||
- name: Install vsce
|
||||
run: npm install -g @vscode/vsce
|
||||
- name: Assert better-sqlite3 native binary present
|
||||
# Force bash: the Windows runner defaults to pwsh, which can't parse this
|
||||
# POSIX test. Git Bash ships on GitHub's windows-latest images.
|
||||
shell: bash
|
||||
run: |
|
||||
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
if [ ! -f "$NODE_FILE" ]; then
|
||||
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
|
||||
echo "(bun trustedDependencies postinstall likely did not run)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found better-sqlite3 native binary: $NODE_FILE"
|
||||
|
||||
# NOTE: vsce is no longer installed globally. @vscode/vsce is a workspace
|
||||
# devDependency of apps/vscode (resolved into node_modules/.bin), and the
|
||||
# `test:e2e:build` script invokes `vsce` via `bun run`, which puts the local
|
||||
# .bin on PATH. No global install needed.
|
||||
|
||||
- name: Install xvfb on Linux
|
||||
if: matrix.runner == 'ubuntu'
|
||||
@@ -148,11 +165,11 @@ jobs:
|
||||
# Run optimized E2E tests (eliminates redundant builds)
|
||||
- name: Run E2E tests - Linux
|
||||
if: matrix.runner == 'ubuntu'
|
||||
run: xvfb-run -a npm run test:e2e:optimal
|
||||
run: xvfb-run -a bun run test:e2e:optimal
|
||||
|
||||
- name: Run E2E tests - Non-Linux
|
||||
if: matrix.runner != 'ubuntu'
|
||||
run: npm run test:e2e:optimal
|
||||
run: bun run test:e2e:optimal
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ failure() }}
|
||||
|
||||
@@ -45,13 +45,16 @@ jobs:
|
||||
- 'apps/vscode/assets/**'
|
||||
- 'apps/vscode/walkthrough/**'
|
||||
- 'apps/vscode/package.json'
|
||||
- 'apps/vscode/package-lock.json'
|
||||
- 'apps/vscode/webview-ui/package.json'
|
||||
# Root bun lockfile is authoritative for the whole workspace (incl. apps/vscode).
|
||||
- 'bun.lock'
|
||||
# SDK source packages are local workspace symlinks (@cline/*), so SDK changes affect the build.
|
||||
- 'sdk/packages/**'
|
||||
- 'apps/vscode/buf.yaml'
|
||||
- 'apps/vscode/tsconfig*.json'
|
||||
- 'apps/vscode/biome.jsonc'
|
||||
- 'apps/vscode/esbuild.mjs'
|
||||
- 'apps/vscode/.mocharc.json'
|
||||
- 'apps/vscode/.nycrc*.json'
|
||||
- 'apps/vscode/bunfig.toml'
|
||||
- 'apps/vscode/.vscode-test.mjs'
|
||||
- 'apps/vscode/test-setup.js'
|
||||
- '.github/workflows/ext-vscode-test.yml'
|
||||
@@ -60,9 +63,13 @@ jobs:
|
||||
- 'apps/vscode/proto/**'
|
||||
- 'apps/vscode/standalone/**'
|
||||
- 'apps/vscode/testing-platform/**'
|
||||
- 'apps/vscode/testing-platform/package.json'
|
||||
- 'apps/vscode/tests/specs/**'
|
||||
- 'apps/vscode/package.json'
|
||||
- 'apps/vscode/package-lock.json'
|
||||
# Root bun lockfile is authoritative for the whole workspace (incl. apps/vscode).
|
||||
- 'bun.lock'
|
||||
# SDK source packages are local workspace symlinks (@cline/*), so SDK changes affect the build.
|
||||
- 'sdk/packages/**'
|
||||
- 'apps/vscode/buf.yaml'
|
||||
- 'apps/vscode/tsconfig*.json'
|
||||
- 'apps/vscode/esbuild.mjs'
|
||||
@@ -82,27 +89,38 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
bun-version: 1.3.14
|
||||
|
||||
- name: Install extension dependencies
|
||||
# Single root install resolves the entire bun workspace (apps/vscode,
|
||||
# webview-ui, testing-platform and the @cline/* SDK symlinks) at once,
|
||||
# so the previous per-package `npm ci` steps collapse into one.
|
||||
- name: Install workspace dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
# @cline/* are local workspace symlinks to source packages; their dist/
|
||||
# output must be built before the extension can type-check/compile.
|
||||
- name: Build SDK packages
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
run: bun run build:sdk
|
||||
|
||||
- name: Assert better-sqlite3 native binary present
|
||||
run: |
|
||||
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
if [ ! -f "$NODE_FILE" ]; then
|
||||
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
|
||||
echo "(bun trustedDependencies postinstall likely did not run)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found better-sqlite3 native binary: $NODE_FILE"
|
||||
|
||||
- name: Run Quality Checks (Parallel)
|
||||
run: npm run ci:check-all
|
||||
run: bun run ci:check-all
|
||||
|
||||
vscode-test:
|
||||
needs: [detect-changes, quality-checks]
|
||||
@@ -123,30 +141,43 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
bun-version: 1.3.14
|
||||
|
||||
- name: Install extension dependencies
|
||||
# Single root install resolves the entire bun workspace at once (replaces
|
||||
# the per-package `npm ci` steps for apps/vscode + webview-ui).
|
||||
- name: Install workspace dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
# @cline/* are local workspace symlinks to source packages; build dist/
|
||||
# before compiling/testing the extension.
|
||||
- name: Build SDK packages
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
run: bun run build:sdk
|
||||
|
||||
- name: Set up NPM on Windows
|
||||
if: runner.os == 'Windows'
|
||||
working-directory: ${{ github.workspace }}
|
||||
- name: Assert better-sqlite3 native binary present
|
||||
run: |
|
||||
npm config set script-shell "C:\\Program Files\\Git\\bin\\bash.exe"
|
||||
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
if [ ! -f "$NODE_FILE" ]; then
|
||||
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
|
||||
echo "(bun trustedDependencies postinstall likely did not run)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found better-sqlite3 native binary: $NODE_FILE"
|
||||
|
||||
# NOTE: The old `npm config set script-shell bash` step is intentionally
|
||||
# removed. Scripts are now launched with `bun run`, which uses Bun's own
|
||||
# built-in cross-platform shell rather than npm's configured script-shell,
|
||||
# so that npm-specific Windows workaround no longer applies. Bash-dependent
|
||||
# scripts (e.g. scripts/proto-lint.sh, standalone/runclinecore.sh) are
|
||||
# invoked explicitly via `bash ...` from within the package scripts, and
|
||||
# this job's `defaults.run.shell: bash` (Git Bash on Windows) still covers
|
||||
# the workflow `run:` blocks below.
|
||||
|
||||
- name: Cache VS Code test runtime
|
||||
if: runner.os == 'Windows'
|
||||
@@ -158,24 +189,51 @@ jobs:
|
||||
# Build the extension and tests (without redundant checks)
|
||||
- name: Build Tests and Extension
|
||||
id: build_step
|
||||
run: npm run ci:build
|
||||
run: bun run ci:build
|
||||
|
||||
- name: Unit Tests with coverage - Linux
|
||||
- name: Vitest Suites (SDK adapter + model catalog)
|
||||
id: vitest_tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
# The vitest config sets passWithNoTests: true, so a broken glob/alias
|
||||
# would "pass" with zero tests. Capture output and assert a non-zero
|
||||
# test count to guard against silent skips.
|
||||
run: |
|
||||
set -o pipefail
|
||||
bun run test:vitest 2>&1 | tee vitest-output.log
|
||||
# Strip ANSI color codes before matching — vitest colorizes the
|
||||
# "Tests N passed" summary, so the count is not adjacent to the
|
||||
# "Tests" label in the raw bytes.
|
||||
if ! sed -r 's/\x1b\[[0-9;]*m//g' vitest-output.log | grep -Eq 'Tests[[:space:]]+[0-9]*[1-9][0-9]* (passed|failed)'; then
|
||||
echo "ERROR: vitest reported zero tests (possible silent skip)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Unit Tests (bun) - Linux
|
||||
id: unit_tests_linux
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os == 'Linux' }}
|
||||
# The node-side unit suite (.mocharc spec set) now runs under `bun test`
|
||||
# via scripts/run-bun-unit-tests.ts (one isolated bun process per file).
|
||||
# The runner exits non-zero on any failure and prints a final
|
||||
# "Files: N Pass: P Fail: F" summary; assert a non-zero pass count to
|
||||
# guard against an empty glob silently "passing".
|
||||
run: |
|
||||
npx nyc --nycrc-path .nycrc.unit.json --reporter=lcov npm run test:unit
|
||||
set -o pipefail
|
||||
bun run test:unit 2>&1 | tee unit-output.log
|
||||
if ! grep -Eq 'Pass:[[:space:]]+[0-9]*[1-9][0-9]*' unit-output.log; then
|
||||
echo "ERROR: bun unit runner reported zero passing tests (possible silent skip)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Unit Tests - Non-Linux
|
||||
- name: Unit Tests (bun) - Non-Linux
|
||||
id: unit_tests_non_linux
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os != 'Linux' }}
|
||||
run: |
|
||||
npm run test:unit
|
||||
bun run test:unit
|
||||
|
||||
- name: Extension Integration Tests - Linux
|
||||
id: integration_tests_linux
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os == 'Linux' }}
|
||||
run: xvfb-run -a npm run test:coverage
|
||||
run: xvfb-run -a bun run test:coverage
|
||||
|
||||
- name: Extension Integration Tests - Non-Linux
|
||||
id: integration_tests_non_linux
|
||||
@@ -183,7 +241,7 @@ jobs:
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
echo "Running extension integration tests (attempt ${attempt}/3)"
|
||||
if npm run test:integration; then
|
||||
if bun run test:integration; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -201,7 +259,7 @@ jobs:
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: |
|
||||
cd webview-ui
|
||||
npm run test:coverage
|
||||
bun run test:coverage
|
||||
|
||||
- name: Save Coverage Reports
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -210,7 +268,6 @@ jobs:
|
||||
with:
|
||||
name: pr-coverage-reports
|
||||
path: |
|
||||
apps/vscode/coverage-unit/lcov.info
|
||||
apps/vscode/webview-ui/coverage/lcov.info
|
||||
|
||||
test-platform-integration:
|
||||
@@ -224,39 +281,45 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
apps/vscode/testing-platform/package-lock.json
|
||||
bun-version: 1.3.14
|
||||
|
||||
- name: Install extension dependencies
|
||||
# Single root install resolves the whole bun workspace, including the
|
||||
# testing-platform package, so the separate per-package `npm ci` steps
|
||||
# (extension + webview-ui + testing-platform) collapse into one.
|
||||
- name: Install workspace dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
# @cline/* are local workspace symlinks to source packages; build dist/
|
||||
# before compiling the standalone core.
|
||||
- name: Build SDK packages
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
run: bun run build:sdk
|
||||
|
||||
- name: Assert better-sqlite3 native binary present
|
||||
run: |
|
||||
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
if [ ! -f "$NODE_FILE" ]; then
|
||||
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
|
||||
echo "(bun trustedDependencies postinstall likely did not run)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found better-sqlite3 native binary: $NODE_FILE"
|
||||
|
||||
- name: Download ripgrep binaries
|
||||
run: npm run download-ripgrep
|
||||
run: bun run download-ripgrep
|
||||
|
||||
- name: Compile Standalone
|
||||
run: npm run compile-standalone
|
||||
|
||||
- name: Install testing platform dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/testing-platform ci
|
||||
run: bun run compile-standalone
|
||||
|
||||
- name: Running testing platform integration spec tests
|
||||
timeout-minutes: 7
|
||||
run: npm run test:tp-orchestrator -- tests/specs/ --count=1 --coverage
|
||||
run: bun run test:tp-orchestrator -- tests/specs/ --count=1 --coverage
|
||||
|
||||
- name: Save Coverage Reports
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -13,6 +13,9 @@ tmp
|
||||
pnpm-lock.yaml
|
||||
|
||||
.clineignore
|
||||
.cline/enterprise
|
||||
.cline/remote-config
|
||||
**/.cline/remote-config
|
||||
.venv
|
||||
.actrc
|
||||
CLAUDE.local.md
|
||||
@@ -81,3 +84,4 @@ apps/vscode/webview-ui/src/**/*.js.map
|
||||
*.db-wal
|
||||
.cline/**/managed.json
|
||||
.cline/**/bundle.json
|
||||
apps/vscode/tsconfig.test.generated.json
|
||||
|
||||
+2
-1
@@ -7,4 +7,5 @@ fi
|
||||
|
||||
gitleaks git --pre-commit --redact --staged --verbose || exit 1
|
||||
|
||||
lint-staged
|
||||
cd apps/vscode && bunx lint-staged
|
||||
|
||||
|
||||
Vendored
+2
-5
@@ -126,10 +126,7 @@
|
||||
"${workspaceFolder}/apps/vscode/dist-standalone/**/*.js"
|
||||
],
|
||||
"preLaunchTask": "compile-standalone",
|
||||
"runtimeExecutable": "npx",
|
||||
"runtimeArgs": [
|
||||
"tsx"
|
||||
],
|
||||
"runtimeExecutable": "bun",
|
||||
"program": "scripts/test-standalone-core-api-server.ts",
|
||||
"envFile": "${workspaceFolder}/apps/vscode/.env",
|
||||
"env": {
|
||||
@@ -183,7 +180,7 @@
|
||||
"name": "Open Storybook",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeExecutable": "bun",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"storybook"
|
||||
|
||||
Vendored
+10
-10
@@ -6,7 +6,7 @@
|
||||
{
|
||||
"label": "compile-standalone",
|
||||
"type": "shell",
|
||||
"command": "npm run compile-standalone",
|
||||
"command": "bun run compile-standalone",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
@@ -19,7 +19,7 @@
|
||||
{
|
||||
"label": "npm: protos",
|
||||
"type": "shell",
|
||||
"command": "npm run protos",
|
||||
"command": "bun run protos",
|
||||
"problemMatcher": [],
|
||||
"isBackground": false,
|
||||
"presentation": {
|
||||
@@ -65,7 +65,7 @@
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "npm run build:webview",
|
||||
"command": "bun run build:webview",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": true,
|
||||
@@ -86,7 +86,7 @@
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "npm run build:webview:test",
|
||||
"command": "bun run build:webview:test",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": true,
|
||||
@@ -108,7 +108,7 @@
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "npm run dev:webview",
|
||||
"command": "bun run dev:webview",
|
||||
"group": "build",
|
||||
"problemMatcher": [
|
||||
{
|
||||
@@ -145,7 +145,7 @@
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "npm run watch:esbuild",
|
||||
"command": "bun run watch:esbuild",
|
||||
"group": "build",
|
||||
"problemMatcher": {
|
||||
"pattern": [
|
||||
@@ -184,7 +184,7 @@
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "npm run watch:esbuild:test",
|
||||
"command": "bun run watch:esbuild:test",
|
||||
"group": "build",
|
||||
"problemMatcher": {
|
||||
"pattern": [
|
||||
@@ -224,7 +224,7 @@
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "npm run watch:tsc",
|
||||
"command": "bun run watch:tsc",
|
||||
"group": "build",
|
||||
"problemMatcher": "$tsc-watch",
|
||||
"isBackground": true,
|
||||
@@ -242,7 +242,7 @@
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "npm run watch-tests",
|
||||
"command": "bun run watch-tests",
|
||||
"label": "npm: watch-tests",
|
||||
"problemMatcher": "$tsc-watch",
|
||||
"isBackground": true,
|
||||
@@ -282,7 +282,7 @@
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "npm run storybook",
|
||||
"command": "bun run storybook",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": false,
|
||||
|
||||
@@ -1,37 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## [4.0.4]
|
||||
|
||||
### Changed
|
||||
|
||||
- Fully remove the ClinePass feature flag so ClinePass is available everywhere in the UI — onboarding, settings, the welcome promo banner, and the credit-limit "Switch to ClinePass" action.
|
||||
|
||||
## [4.0.3]
|
||||
|
||||
### Changed
|
||||
|
||||
- Enable the ClinePass provider for all users by removing the feature-flag gate that previously fell back to the standard Cline provider.
|
||||
|
||||
## [4.0.2]
|
||||
|
||||
### Added
|
||||
|
||||
- Add reasoning effort support (including `xhigh`) for DeepSeek thinking models.
|
||||
- Improve the ClinePass provider experience with clearer reasoning controls and model selection.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Show reasoning effort controls for ClinePass models and align ClinePass model resolution with the rest of the provider.
|
||||
- Prefer canonical Cline Z.ai model ids and polish ClinePass and Z.ai model metadata.
|
||||
- Fix environment variable replacement in the webview.
|
||||
- Default focus chain settings in webview state so the toggle reflects the correct value on load.
|
||||
|
||||
## [4.0.1]
|
||||
|
||||
### Changed
|
||||
|
||||
- Roll the stable VS Code extension back to the pre-SDK-migration codebase to resolve regressions reported in 4.0.0. This release ships the 3.89.2 extension code under a higher version number so existing 4.0.0 users receive the update. SDK-migration work continues separately on `main`.
|
||||
|
||||
## [3.89.2]
|
||||
|
||||
### Fixed
|
||||
|
||||
+14
-14
@@ -45,7 +45,7 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
|
||||
3. Install [bun](https://bun.com)
|
||||
4. Install the necessary dependencies for the extension and webview-gui:
|
||||
```bash
|
||||
cd apps/vscode && npm run install:all && cd ../..
|
||||
cd apps/vscode && bun run install:all && cd ../..
|
||||
cd sdk && bun run build && cd ..
|
||||
```
|
||||
5. Generate Protocol Buffer files (required before first build):
|
||||
@@ -61,8 +61,8 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
|
||||
2. Push your branch and create a PR on GitHub. Our CI will:
|
||||
- Run tests and checks
|
||||
3. Testing
|
||||
- Run `cd apps/vscode && npm run test` to run tests locally.
|
||||
- Before submitting PR, run `npm run format:fix` to format your code
|
||||
- Run `cd apps/vscode && bun run test` to run tests locally.
|
||||
- Before submitting PR, run `bun run format:fix` to format your code
|
||||
|
||||
### Extension
|
||||
|
||||
@@ -74,12 +74,12 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
|
||||
|
||||
2. **Local Development**
|
||||
- cd into the vscode extension, `cd apps/vscode`
|
||||
- Run `npm run install:all` to install dependencies
|
||||
- Run `npm run protos` to generate Protocol Buffer files (required before first build)
|
||||
- Run `npm run test` to run tests locally
|
||||
- Run `bun run install:all` to install dependencies
|
||||
- Run `bun run protos` to generate Protocol Buffer files (required before first build)
|
||||
- Run `bun run test` to run tests locally
|
||||
- Run → Start Debugging or `>Debug: Select and Start Debugging` and wait for a new VS Code instance to open
|
||||
- **Terminal Workflow**: Use `npm run dev` (generates protos + runs watch mode) or `npm run watch` (if protos already generated)
|
||||
- Before submitting PR, run `npm run format:fix` to format your code
|
||||
- **Terminal Workflow**: Use `bun run dev` (generates protos + runs watch mode) or `bun run watch` (if protos already generated)
|
||||
- Before submitting PR, run `bun run format:fix` to format your code
|
||||
|
||||
3. **Linux-specific Setup**
|
||||
VS Code extension tests on Linux require the following system libraries:
|
||||
@@ -135,8 +135,8 @@ Anyone can contribute code to Cline, but we ask that you follow these guidelines
|
||||
|
||||
2. **Code Quality**
|
||||
|
||||
- Run `npm run lint` to check code style
|
||||
- Run `npm run format` to automatically format code
|
||||
- Run `bun run lint` to check code style
|
||||
- Run `bun run format` to automatically format code
|
||||
- All PRs must pass CI checks which include both linting and formatting
|
||||
- Address any warnings or errors from linter before submitting
|
||||
- Follow TypeScript best practices and maintain type safety
|
||||
@@ -144,7 +144,7 @@ Anyone can contribute code to Cline, but we ask that you follow these guidelines
|
||||
3. **Testing**
|
||||
|
||||
- Add tests for new features
|
||||
- Run `npm test` to ensure all tests pass
|
||||
- Run `bun test` to ensure all tests pass
|
||||
- Update existing tests if your changes affect them
|
||||
- Include both unit tests and integration tests where appropriate
|
||||
|
||||
@@ -154,9 +154,9 @@ Anyone can contribute code to Cline, but we ask that you follow these guidelines
|
||||
|
||||
- **Running E2E tests:**
|
||||
```bash
|
||||
npm run test:e2e # Build and run all E2E tests
|
||||
npm run e2e # Run tests without rebuilding
|
||||
npm run test:e2e -- --debug # Run with interactive debugger
|
||||
bun run test:e2e # Build and run all E2E tests
|
||||
bun run e2e # Run tests without rebuilding
|
||||
bun run test:e2e -- --debug # Run with interactive debugger
|
||||
```
|
||||
|
||||
- **Writing E2E tests:**
|
||||
|
||||
@@ -26,7 +26,6 @@ test.describe("root flag descriptions", () => {
|
||||
"Set reasoning effort level",
|
||||
"consecutive mistakes",
|
||||
"Output messages as JSON",
|
||||
"ACP",
|
||||
"Check for updates and install if available",
|
||||
"Run the kanban app",
|
||||
]);
|
||||
|
||||
@@ -57,7 +57,7 @@ describe("formatStatusBarUsageText", () => {
|
||||
totalCost: 0.123,
|
||||
showCost: true,
|
||||
}),
|
||||
).toBe("(12,345 tokens) $0.12");
|
||||
).toBe("(12,345) $0.12");
|
||||
});
|
||||
|
||||
it("omits cost when usage cost is hidden", () => {
|
||||
@@ -67,6 +67,6 @@ describe("formatStatusBarUsageText", () => {
|
||||
totalCost: 0.123,
|
||||
showCost: false,
|
||||
}),
|
||||
).toBe("(12,345 tokens)");
|
||||
).toBe("(12,345)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ export function formatStatusBarUsageText(input: {
|
||||
totalCost: number;
|
||||
showCost: boolean;
|
||||
}): string {
|
||||
const tokens = `(${input.totalTokens.toLocaleString()} tokens)`;
|
||||
const tokens = `(${input.totalTokens.toLocaleString()})`;
|
||||
if (!input.showCost) return tokens;
|
||||
return `${tokens} ${formatCost(input.totalCost)}`;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,17 @@ describe("MCP wizard settings", () => {
|
||||
expect(Object.keys(parsed.mcpServers ?? {})).toEqual(["added"]);
|
||||
});
|
||||
|
||||
it("creates the settings file when adding a server to a missing path", async () => {
|
||||
const settingsPath = await useTempSettingsPath();
|
||||
|
||||
addServer("added", { type: "stdio", command: "npx", args: ["server"] });
|
||||
|
||||
const parsed = JSON.parse(await readFile(settingsPath, "utf8")) as {
|
||||
mcpServers?: Record<string, unknown>;
|
||||
};
|
||||
expect(Object.keys(parsed.mcpServers ?? {})).toEqual(["added"]);
|
||||
});
|
||||
|
||||
it("parses quoted stdio command arguments", () => {
|
||||
expect(
|
||||
parseStdioCommand('npx -y "@scope/server name" --root "my dir"'),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import {
|
||||
type McpServerOAuthState,
|
||||
McpSettingsUpdateSkippedError,
|
||||
resolveDefaultMcpSettingsPath,
|
||||
updateMcpSettingsFileSync,
|
||||
} from "@cline/core";
|
||||
|
||||
export interface McpServerEntry {
|
||||
@@ -56,28 +57,6 @@ export function loadServers(): McpServerEntry[] {
|
||||
}
|
||||
}
|
||||
|
||||
function readRawSettings(): Record<string, unknown> {
|
||||
const path = getSettingsPath();
|
||||
if (!existsSync(path)) return {};
|
||||
try {
|
||||
const raw = readFileSync(path, "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function readRawServers(): Record<string, unknown> {
|
||||
const settings = readRawSettings();
|
||||
const servers = settings.mcpServers;
|
||||
return servers && typeof servers === "object" && !Array.isArray(servers)
|
||||
? { ...(servers as Record<string, unknown>) }
|
||||
: {};
|
||||
}
|
||||
|
||||
function getOwnServerRecord(
|
||||
servers: Record<string, unknown>,
|
||||
name: string,
|
||||
@@ -92,62 +71,85 @@ function getOwnServerRecord(
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function writeServers(servers: Record<string, unknown>): void {
|
||||
const path = getSettingsPath();
|
||||
const settings = readRawSettings();
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(
|
||||
path,
|
||||
`${JSON.stringify({ ...settings, mcpServers: servers }, null, 2)}\n`,
|
||||
);
|
||||
/**
|
||||
* Mutate the MCP settings file through @cline/core's locked read-update-write
|
||||
* helper. The mutator must be synchronous and pure; the helper may call it more
|
||||
* than once to verify deterministic output. Throw McpSettingsUpdateSkippedError
|
||||
* for normal no-op cases instead of returning a boolean that callers can ignore.
|
||||
*/
|
||||
function mutateServers(mutate: (servers: Record<string, unknown>) => void): void {
|
||||
updateMcpSettingsFileSync(getSettingsPath(), (settings) => {
|
||||
const serversValue = settings.mcpServers;
|
||||
const servers = serversValue && typeof serversValue === "object" && !Array.isArray(serversValue)
|
||||
? { ...(serversValue as Record<string, unknown>) }
|
||||
: {};
|
||||
mutate(servers);
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
}
|
||||
|
||||
export function addServer(name: string, transport: McpTransport): void {
|
||||
const servers = readRawServers();
|
||||
servers[name] = { transport };
|
||||
writeServers(servers);
|
||||
mutateServers((servers) => {
|
||||
servers[name] = { transport };
|
||||
});
|
||||
}
|
||||
|
||||
export function removeServer(name: string): boolean {
|
||||
const servers = readRawServers();
|
||||
if (!(name in servers)) return false;
|
||||
delete servers[name];
|
||||
writeServers(servers);
|
||||
return true;
|
||||
try {
|
||||
mutateServers((servers) => {
|
||||
if (!(name in servers)) {
|
||||
throw new McpSettingsUpdateSkippedError(`MCP server not found: ${name}`);
|
||||
}
|
||||
delete servers[name];
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof McpSettingsUpdateSkippedError) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function updateServer(name: string, transport: McpTransport): void {
|
||||
const servers = readRawServers();
|
||||
const existing =
|
||||
servers[name] && typeof servers[name] === "object"
|
||||
? (servers[name] as Record<string, unknown>)
|
||||
: {};
|
||||
servers[name] = { ...existing, transport };
|
||||
writeServers(servers);
|
||||
mutateServers((servers) => {
|
||||
const existing =
|
||||
servers[name] && typeof servers[name] === "object"
|
||||
? (servers[name] as Record<string, unknown>)
|
||||
: {};
|
||||
servers[name] = { ...existing, transport };
|
||||
});
|
||||
}
|
||||
|
||||
export function clearServerOAuth(name: string): void {
|
||||
const servers = readRawServers();
|
||||
const existing = getOwnServerRecord(servers, name);
|
||||
if (!existing) {
|
||||
return;
|
||||
try {
|
||||
mutateServers((servers) => {
|
||||
const existing = getOwnServerRecord(servers, name);
|
||||
if (!existing) {
|
||||
throw new McpSettingsUpdateSkippedError(`MCP server not found: ${name}`);
|
||||
}
|
||||
delete existing.oauth;
|
||||
servers[name] = existing;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof McpSettingsUpdateSkippedError) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
delete existing.oauth;
|
||||
servers[name] = existing;
|
||||
writeServers(servers);
|
||||
}
|
||||
|
||||
export function toggleServer(name: string, disabled: boolean): void {
|
||||
const servers = readRawServers();
|
||||
const existing =
|
||||
servers[name] && typeof servers[name] === "object"
|
||||
? (servers[name] as Record<string, unknown>)
|
||||
: {};
|
||||
if (disabled) {
|
||||
existing.disabled = true;
|
||||
} else {
|
||||
delete existing.disabled;
|
||||
}
|
||||
servers[name] = existing;
|
||||
writeServers(servers);
|
||||
mutateServers((servers) => {
|
||||
const existing =
|
||||
servers[name] && typeof servers[name] === "object"
|
||||
? (servers[name] as Record<string, unknown>)
|
||||
: {};
|
||||
if (disabled) {
|
||||
existing.disabled = true;
|
||||
} else {
|
||||
delete existing.disabled;
|
||||
}
|
||||
servers[name] = existing;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { isIP } from "node:net";
|
||||
|
||||
export interface ClineHubServerOptions {
|
||||
host: string;
|
||||
port: number;
|
||||
@@ -49,9 +47,6 @@ function normalizePublicUrl(
|
||||
`PUBLIC_URL must use http: or https:, got ${parsed.protocol}`,
|
||||
);
|
||||
}
|
||||
if (shouldAddDashboardPortToPublicUrl(parsed, port)) {
|
||||
parsed.port = String(port);
|
||||
}
|
||||
parsed.hash = "";
|
||||
return parsed.toString().replace(/\/$/, "");
|
||||
}
|
||||
@@ -90,26 +85,12 @@ export function resolveClineHubServerOptions(
|
||||
};
|
||||
}
|
||||
|
||||
function isDefaultProtocolPort(url: URL, port: number): boolean {
|
||||
return (
|
||||
(url.protocol === "http:" && port === 80) ||
|
||||
(url.protocol === "https:" && port === 443)
|
||||
);
|
||||
}
|
||||
|
||||
function shouldAddDashboardPortToPublicUrl(url: URL, port: number): boolean {
|
||||
if (url.port || isDefaultProtocolPort(url, port)) return false;
|
||||
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
||||
return hostname === "localhost" || isIP(hostname) !== 0;
|
||||
}
|
||||
|
||||
export function buildInviteUrl(
|
||||
publicUrl: string,
|
||||
roomSecret: string | undefined,
|
||||
): string {
|
||||
if (!roomSecret) return publicUrl;
|
||||
const url = new URL(publicUrl);
|
||||
if (roomSecret) {
|
||||
url.searchParams.set("roomSecret", roomSecret);
|
||||
}
|
||||
url.searchParams.set("roomSecret", roomSecret);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
handleToolApprovalResponse,
|
||||
rejectOrphanedApprovals,
|
||||
} from "./server/approvals";
|
||||
import { isAuthorizedBrowserToDesktopRequest } from "./server/browser-auth";
|
||||
import {
|
||||
browserConfig,
|
||||
host,
|
||||
@@ -15,11 +14,7 @@ import {
|
||||
webviewDistDir,
|
||||
} from "./server/deps";
|
||||
import { handleDesktopCommand } from "./server/desktop-commands";
|
||||
import {
|
||||
createJsonResponse,
|
||||
isWebviewRoute,
|
||||
WebviewAssets,
|
||||
} from "./server/http";
|
||||
import { createJsonResponse, WebviewAssets } from "./server/http";
|
||||
import {
|
||||
attachHub,
|
||||
detachHub,
|
||||
@@ -58,33 +53,17 @@ export interface ClineHubDashboardServer {
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
const PUBLIC_BROWSER_PATHS = new Set([
|
||||
"/version",
|
||||
"/health",
|
||||
"/config.json",
|
||||
"/api/marketplace/catalog",
|
||||
"/icon.png",
|
||||
"/icon.svg",
|
||||
"/icon.ico",
|
||||
"/32x32.png",
|
||||
"/cline-logo-filled.svg",
|
||||
"/favicon.svg",
|
||||
]);
|
||||
|
||||
function isPublicStaticAssetPath(pathname: string): boolean {
|
||||
return pathname.startsWith("/assets/") || PUBLIC_BROWSER_PATHS.has(pathname);
|
||||
}
|
||||
|
||||
function isPublicBrowserRoute(_req: Request, url: URL): boolean {
|
||||
return isWebviewRoute(url.pathname) || isPublicStaticAssetPath(url.pathname);
|
||||
}
|
||||
|
||||
export async function startClineHubDashboardServer(): Promise<ClineHubDashboardServer> {
|
||||
const ctx = new HubContext();
|
||||
const assets = new WebviewAssets(webviewDistDir);
|
||||
const syncClientsAndSessions = () => syncHubClientsAndSessions(ctx);
|
||||
let stopped = false;
|
||||
|
||||
function isAuthorizedBrowserRequest(url: URL): boolean {
|
||||
if (!roomSecret) return true;
|
||||
return url.searchParams.get("roomSecret") === roomSecret;
|
||||
}
|
||||
|
||||
await attachHub(ctx);
|
||||
const healthInterval = setInterval(() => {
|
||||
void (async () => {
|
||||
@@ -98,21 +77,6 @@ export async function startClineHubDashboardServer(): Promise<ClineHubDashboardS
|
||||
hostname: host,
|
||||
async fetch(req, server) {
|
||||
const url = new URL(req.url);
|
||||
if (
|
||||
!isAuthorizedBrowserToDesktopRequest(
|
||||
req,
|
||||
url,
|
||||
{
|
||||
bindHost: host,
|
||||
port,
|
||||
publicUrl,
|
||||
roomSecret,
|
||||
},
|
||||
isPublicBrowserRoute,
|
||||
)
|
||||
) {
|
||||
return createJsonResponse({ error: "unauthorized_browser" }, 403);
|
||||
}
|
||||
if (url.pathname === "/version") {
|
||||
return createJsonResponse({ coreVersion: CORE_BUILD_VERSION });
|
||||
}
|
||||
@@ -121,6 +85,9 @@ export async function startClineHubDashboardServer(): Promise<ClineHubDashboardS
|
||||
return createJsonResponse(hubStatusPayload(ctx));
|
||||
}
|
||||
if (url.pathname === "/browser") {
|
||||
if (!isAuthorizedBrowserRequest(url)) {
|
||||
return createJsonResponse({ error: "invalid_room_secret" }, 401);
|
||||
}
|
||||
const displayName = `Browser ${Math.random().toString(36).slice(2, 6)}`;
|
||||
const data = {
|
||||
socket: undefined as never,
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
allowedBrowserHosts,
|
||||
allowedBrowserOrigins,
|
||||
isAuthorizedBrowserRequest,
|
||||
isAuthorizedBrowserToDesktopRequest,
|
||||
requiresBrowserRequestAuth,
|
||||
} from "./browser-auth";
|
||||
|
||||
const defaultOptions = {
|
||||
bindHost: "127.0.0.1",
|
||||
port: 8787,
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
};
|
||||
|
||||
const publicRoute = (_req: Request, url: URL) => url.pathname === "/public";
|
||||
|
||||
function browserRequest(
|
||||
origin?: string,
|
||||
init?: Omit<RequestInit, "headers"> & {
|
||||
headers?: Record<string, string>;
|
||||
},
|
||||
): Request {
|
||||
return new Request("http://127.0.0.1:8787/browser", {
|
||||
...init,
|
||||
headers: {
|
||||
host: "127.0.0.1:8787",
|
||||
...(origin === undefined ? {} : { origin }),
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("allowedBrowserOrigins", () => {
|
||||
it("allows the configured public URL origin and local aliases for local binds", () => {
|
||||
expect([...allowedBrowserOrigins(defaultOptions)].sort()).toEqual([
|
||||
"http://127.0.0.1:8787",
|
||||
"http://[::1]:8787",
|
||||
"http://localhost:8787",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the configured public URL scheme for local aliases", () => {
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserOrigins({
|
||||
...defaultOptions,
|
||||
publicUrl: "https://127.0.0.1:8787",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual([
|
||||
"https://127.0.0.1:8787",
|
||||
"https://[::1]:8787",
|
||||
"https://localhost:8787",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits default protocol ports for local alias origins", () => {
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserOrigins({
|
||||
bindHost: "127.0.0.1",
|
||||
port: 80,
|
||||
publicUrl: "http://localhost",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["http://127.0.0.1", "http://[::1]", "http://localhost"]);
|
||||
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserOrigins({
|
||||
bindHost: "127.0.0.1",
|
||||
port: 443,
|
||||
publicUrl: "https://localhost",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["https://127.0.0.1", "https://[::1]", "https://localhost"]);
|
||||
});
|
||||
|
||||
it("allows the configured public URL origin and explicit bind origin for non-local binds", () => {
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserOrigins({
|
||||
bindHost: "0.0.0.0",
|
||||
port: 8787,
|
||||
publicUrl: "https://example.ngrok-free.app",
|
||||
roomSecret: "secret",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["https://0.0.0.0:8787", "https://example.ngrok-free.app"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("allowedBrowserHosts", () => {
|
||||
it("allows the configured public URL host and local aliases for local binds", () => {
|
||||
expect([...allowedBrowserHosts(defaultOptions)].sort()).toEqual([
|
||||
"127.0.0.1:8787",
|
||||
"[::1]:8787",
|
||||
"localhost:8787",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits default protocol ports for local alias hosts", () => {
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserHosts({
|
||||
bindHost: "127.0.0.1",
|
||||
port: 80,
|
||||
publicUrl: "http://localhost",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["127.0.0.1", "[::1]", "localhost"]);
|
||||
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserHosts({
|
||||
bindHost: "127.0.0.1",
|
||||
port: 443,
|
||||
publicUrl: "https://localhost",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["127.0.0.1", "[::1]", "localhost"]);
|
||||
});
|
||||
|
||||
it("allows the configured public URL host and explicit bind host for non-local binds", () => {
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserHosts({
|
||||
bindHost: "0.0.0.0",
|
||||
port: 8787,
|
||||
publicUrl: "https://example.ngrok-free.app",
|
||||
roomSecret: "secret",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["0.0.0.0:8787", "example.ngrok-free.app"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requiresBrowserRequestAuth", () => {
|
||||
it("does not require browser auth for public GET routes", () => {
|
||||
expect(
|
||||
requiresBrowserRequestAuth(
|
||||
new Request("http://127.0.0.1:8787/public"),
|
||||
new URL("http://127.0.0.1:8787/public"),
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("requires browser auth for unknown paths even when they use GET", () => {
|
||||
expect(
|
||||
requiresBrowserRequestAuth(
|
||||
new Request("http://127.0.0.1:8787/future-api"),
|
||||
new URL("http://127.0.0.1:8787/future-api"),
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires browser auth for privileged paths even when they use GET", () => {
|
||||
expect(
|
||||
requiresBrowserRequestAuth(
|
||||
new Request("http://127.0.0.1:8787/browser"),
|
||||
new URL("http://127.0.0.1:8787/browser"),
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires browser auth for every WebSocket upgrade path", () => {
|
||||
expect(
|
||||
requiresBrowserRequestAuth(
|
||||
new Request("http://127.0.0.1:8787/future-socket", {
|
||||
headers: { upgrade: "websocket" },
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/future-socket"),
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires browser auth for every unsafe HTTP method", () => {
|
||||
expect(
|
||||
requiresBrowserRequestAuth(
|
||||
new Request("http://127.0.0.1:8787/future-api", { method: "POST" }),
|
||||
new URL("http://127.0.0.1:8787/future-api"),
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAuthorizedBrowserRequest", () => {
|
||||
it.each([
|
||||
"http://127.0.0.1:8787",
|
||||
"http://localhost:8787",
|
||||
"http://[::1]:8787",
|
||||
])("accepts local dashboard origin %s without a room secret", (origin) => {
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest(origin),
|
||||
new URL("http://127.0.0.1:8787/browser"),
|
||||
defaultOptions,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
"",
|
||||
"null",
|
||||
"not a url",
|
||||
"http://evil.attacker.example.com",
|
||||
"http://127.0.0.1:9999",
|
||||
"https://127.0.0.1:8787",
|
||||
])("rejects untrusted origin %s", (origin) => {
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest(origin),
|
||||
new URL("http://127.0.0.1:8787/browser"),
|
||||
defaultOptions,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
"",
|
||||
"evil.attacker.example.com",
|
||||
"127.0.0.1:9999",
|
||||
"localhost:9999",
|
||||
])("rejects untrusted host %s", (host) => {
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://127.0.0.1:8787", {
|
||||
headers: host === undefined ? { host: "" } : { host },
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/browser"),
|
||||
defaultOptions,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows explicit wildcard bind host and origin when a room secret is configured", () => {
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://0.0.0.0:8787", {
|
||||
headers: { host: "0.0.0.0:8787" },
|
||||
}),
|
||||
new URL("http://0.0.0.0:8787/browser?roomSecret=invite-123"),
|
||||
{
|
||||
bindHost: "0.0.0.0",
|
||||
port: 8787,
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
roomSecret: "invite-123",
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires trusted origin, trusted host, and room secret when a room secret is configured", () => {
|
||||
const options = { ...defaultOptions, roomSecret: "invite-123" };
|
||||
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://127.0.0.1:8787"),
|
||||
new URL("http://127.0.0.1:8787/browser?roomSecret=invite-123"),
|
||||
options,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://127.0.0.1:8787"),
|
||||
new URL("http://127.0.0.1:8787/browser"),
|
||||
options,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://evil.attacker.example.com"),
|
||||
new URL("http://127.0.0.1:8787/browser?roomSecret=invite-123"),
|
||||
options,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://127.0.0.1:8787", {
|
||||
headers: { host: "evil.attacker.example.com" },
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/browser?roomSecret=invite-123"),
|
||||
options,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAuthorizedBrowserToDesktopRequest", () => {
|
||||
it("allows safe public GET routes without an origin", () => {
|
||||
expect(
|
||||
isAuthorizedBrowserToDesktopRequest(
|
||||
new Request("http://127.0.0.1:8787/public"),
|
||||
new URL("http://127.0.0.1:8787/public"),
|
||||
defaultOptions,
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects future WebSocket paths from untrusted origins by default", () => {
|
||||
expect(
|
||||
isAuthorizedBrowserToDesktopRequest(
|
||||
new Request("http://127.0.0.1:8787/future-socket", {
|
||||
headers: {
|
||||
host: "127.0.0.1:8787",
|
||||
origin: "http://evil.attacker.example.com",
|
||||
upgrade: "websocket",
|
||||
},
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/future-socket"),
|
||||
defaultOptions,
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects future unsafe HTTP routes from untrusted origins by default", () => {
|
||||
expect(
|
||||
isAuthorizedBrowserToDesktopRequest(
|
||||
new Request("http://127.0.0.1:8787/future-api", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
host: "127.0.0.1:8787",
|
||||
origin: "http://evil.attacker.example.com",
|
||||
},
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/future-api"),
|
||||
defaultOptions,
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows future unsafe HTTP routes from trusted origins", () => {
|
||||
expect(
|
||||
isAuthorizedBrowserToDesktopRequest(
|
||||
new Request("http://127.0.0.1:8787/future-api", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
host: "127.0.0.1:8787",
|
||||
origin: "http://127.0.0.1:8787",
|
||||
},
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/future-api"),
|
||||
defaultOptions,
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
import { isNonLocalBindHost } from "../options";
|
||||
|
||||
export interface BrowserRequestAuthOptions {
|
||||
bindHost: string;
|
||||
port: number;
|
||||
publicUrl: string;
|
||||
roomSecret?: string;
|
||||
}
|
||||
|
||||
const SAFE_HTTP_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
|
||||
export type PublicBrowserRoutePredicate = (req: Request, url: URL) => boolean;
|
||||
|
||||
function isWebSocketUpgrade(req: Request): boolean {
|
||||
return req.headers.get("upgrade")?.toLowerCase() === "websocket";
|
||||
}
|
||||
|
||||
function parseOrigin(value: string | null): string | undefined {
|
||||
const origin = parseHeader(value);
|
||||
try {
|
||||
return new URL(origin ?? "").origin;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseHeader(value: string | null): string | undefined {
|
||||
const host = value?.trim().toLowerCase();
|
||||
return host || undefined;
|
||||
}
|
||||
|
||||
function formatHostForOrigin(host: string): string {
|
||||
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
||||
}
|
||||
|
||||
function isDefaultProtocolPort(protocol: string, port: number): boolean {
|
||||
return (
|
||||
(protocol === "http:" && port === 80) ||
|
||||
(protocol === "https:" && port === 443)
|
||||
);
|
||||
}
|
||||
|
||||
function originForHost(protocol: string, host: string, port: number): string {
|
||||
return new URL(`${protocol}//${formatHostForOrigin(host)}:${port}`).origin;
|
||||
}
|
||||
|
||||
function hostHeaderForHost(
|
||||
protocol: string,
|
||||
host: string,
|
||||
port: number,
|
||||
): string {
|
||||
const formattedHost = formatHostForOrigin(host).toLowerCase();
|
||||
return isDefaultProtocolPort(protocol, port)
|
||||
? formattedHost
|
||||
: `${formattedHost}:${port}`;
|
||||
}
|
||||
|
||||
export function allowedBrowserOrigins({
|
||||
bindHost,
|
||||
port,
|
||||
publicUrl,
|
||||
}: BrowserRequestAuthOptions): Set<string> {
|
||||
const publicUrlParts = new URL(publicUrl);
|
||||
const origins = new Set<string>();
|
||||
origins.add(publicUrlParts.origin);
|
||||
|
||||
origins.add(originForHost(publicUrlParts.protocol, bindHost, port));
|
||||
|
||||
if (!isNonLocalBindHost(bindHost)) {
|
||||
for (const hostname of ["127.0.0.1", "localhost", "[::1]"]) {
|
||||
origins.add(originForHost(publicUrlParts.protocol, hostname, port));
|
||||
}
|
||||
}
|
||||
|
||||
return origins;
|
||||
}
|
||||
|
||||
export function allowedBrowserHosts({
|
||||
bindHost,
|
||||
port,
|
||||
publicUrl,
|
||||
}: BrowserRequestAuthOptions): Set<string> {
|
||||
const publicUrlParts = new URL(publicUrl);
|
||||
const hosts = new Set<string>();
|
||||
const publicHost = publicUrlParts.host.toLowerCase();
|
||||
hosts.add(publicHost);
|
||||
|
||||
hosts.add(hostHeaderForHost(publicUrlParts.protocol, bindHost, port));
|
||||
|
||||
if (!isNonLocalBindHost(bindHost)) {
|
||||
for (const hostname of ["127.0.0.1", "localhost", "[::1]"]) {
|
||||
hosts.add(hostHeaderForHost(publicUrlParts.protocol, hostname, port));
|
||||
}
|
||||
}
|
||||
|
||||
return hosts;
|
||||
}
|
||||
|
||||
export function requiresBrowserRequestAuth(
|
||||
req: Request,
|
||||
url: URL,
|
||||
isPublicBrowserRoute: PublicBrowserRoutePredicate,
|
||||
): boolean {
|
||||
if (isWebSocketUpgrade(req)) return true;
|
||||
if (!SAFE_HTTP_METHODS.has(req.method.toUpperCase())) return true;
|
||||
return !isPublicBrowserRoute(req, url);
|
||||
}
|
||||
|
||||
export function isAuthorizedBrowserRequest(
|
||||
req: Request,
|
||||
url: URL,
|
||||
options: BrowserRequestAuthOptions,
|
||||
): boolean {
|
||||
const host = parseHeader(req.headers.get("host"));
|
||||
if (!host || !allowedBrowserHosts(options).has(host)) return false;
|
||||
|
||||
const origin = parseOrigin(req.headers.get("origin"));
|
||||
if (!origin || !allowedBrowserOrigins(options).has(origin)) return false;
|
||||
|
||||
if (!options.roomSecret) return true;
|
||||
return url.searchParams.get("roomSecret") === options.roomSecret;
|
||||
}
|
||||
|
||||
export function isAuthorizedBrowserToDesktopRequest(
|
||||
req: Request,
|
||||
url: URL,
|
||||
options: BrowserRequestAuthOptions,
|
||||
isPublicBrowserRoute: PublicBrowserRoutePredicate,
|
||||
): boolean {
|
||||
return (
|
||||
!requiresBrowserRequestAuth(req, url, isPublicBrowserRoute) ||
|
||||
isAuthorizedBrowserRequest(req, url, options)
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { updateMcpSettingsFileSync } from "@cline/core";
|
||||
import { resolveMcpSettingsPath } from "@cline/shared/storage";
|
||||
import type { JsonRecord } from "./types";
|
||||
|
||||
@@ -65,9 +65,9 @@ export function readMcpServersResponse(): JsonRecord {
|
||||
}
|
||||
|
||||
export function writeMcpServersMap(servers: JsonRecord): void {
|
||||
const path = resolveMcpSettingsPath();
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, `${JSON.stringify({ mcpServers: servers }, null, 2)}\n`);
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
}
|
||||
|
||||
export function ensureMcpSettingsFile(): string {
|
||||
@@ -78,23 +78,21 @@ export function ensureMcpSettingsFile(): string {
|
||||
return path;
|
||||
}
|
||||
|
||||
function readServersMap(): { path: string; servers: JsonRecord } {
|
||||
const path = ensureMcpSettingsFile();
|
||||
const parsed = JSON.parse(readFileSync(path, "utf8")) as JsonRecord;
|
||||
return { path, servers: (parsed.mcpServers as JsonRecord | undefined) ?? {} };
|
||||
}
|
||||
|
||||
export function setMcpServerDisabled(
|
||||
name: string,
|
||||
disabled: boolean,
|
||||
): JsonRecord {
|
||||
const { servers } = readServersMap();
|
||||
const current = servers[name];
|
||||
if (!current || typeof current !== "object") {
|
||||
throw new Error(`unknown MCP server: ${name}`);
|
||||
}
|
||||
servers[name] = { ...(current as JsonRecord), disabled };
|
||||
writeMcpServersMap(servers);
|
||||
// Hold the cross-process lock across read-modify-write so a concurrent writer
|
||||
// (the extension, the CLI) cannot clobber this change.
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
|
||||
const current = servers[name];
|
||||
if (!current || typeof current !== "object") {
|
||||
throw new Error(`unknown MCP server: ${name}`);
|
||||
}
|
||||
servers[name] = { ...(current as JsonRecord), disabled };
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
|
||||
@@ -127,19 +125,27 @@ export function upsertMcpServer(input: JsonRecord): JsonRecord {
|
||||
},
|
||||
disabled: input.disabled === true,
|
||||
};
|
||||
const { servers } = readServersMap();
|
||||
if (previousName && previousName !== name) {
|
||||
delete servers[previousName];
|
||||
}
|
||||
servers[name] = next;
|
||||
writeMcpServersMap(servers);
|
||||
// Hold the cross-process lock across read-modify-write so a concurrent writer
|
||||
// cannot clobber this upsert.
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
|
||||
if (previousName && previousName !== name) {
|
||||
delete servers[previousName];
|
||||
}
|
||||
servers[name] = next;
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
|
||||
export function deleteMcpServer(name: string): JsonRecord {
|
||||
if (!name) throw new Error("server name is required");
|
||||
const { servers } = readServersMap();
|
||||
delete servers[name];
|
||||
writeMcpServersMap(servers);
|
||||
// Hold the cross-process lock across read-modify-write so a concurrent writer
|
||||
// cannot resurrect the deleted server from a stale snapshot.
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
|
||||
delete servers[name];
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
|
||||
@@ -41,23 +41,6 @@ expectEqual(
|
||||
"invite URL",
|
||||
);
|
||||
|
||||
const tailscale = resolveClineHubServerOptions({
|
||||
HOST: "0.0.0.0",
|
||||
CLINE_HUB_DASHBOARD_PORT: "8787",
|
||||
PUBLIC_URL: "http://100.82.5.118",
|
||||
ROOM_SECRET: "invite-123",
|
||||
});
|
||||
expectEqual(
|
||||
tailscale.publicUrl,
|
||||
"http://100.82.5.118:8787",
|
||||
"direct IP public URL gets dashboard port",
|
||||
);
|
||||
expectEqual(
|
||||
buildInviteUrl(tailscale.publicUrl, tailscale.roomSecret),
|
||||
"http://100.82.5.118:8787/?roomSecret=invite-123",
|
||||
"invite URL for direct IP public URL",
|
||||
);
|
||||
|
||||
expectThrows(
|
||||
() => resolveClineHubServerOptions({ HOST: "0.0.0.0" }),
|
||||
"non-local bind without ROOM_SECRET",
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { basename, dirname, extname, join } from "node:path";
|
||||
import type {
|
||||
@@ -45,6 +43,7 @@ import {
|
||||
setDisabledPlugin,
|
||||
setDisabledTools,
|
||||
toggleDisabledTool,
|
||||
updateMcpSettingsFileSync,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import { broadcastEvent, resolveSidecarAskQuestion } from "./context";
|
||||
@@ -143,9 +142,9 @@ function readMcpServersResponse(): JsonRecord {
|
||||
}
|
||||
|
||||
function writeMcpServersMap(servers: JsonRecord): void {
|
||||
const path = resolveMcpSettingsPath();
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, `${JSON.stringify({ mcpServers: servers }, null, 2)}\n`);
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
}
|
||||
|
||||
function ensureMcpSettingsFile(): string {
|
||||
@@ -1043,18 +1042,19 @@ export async function handleCommand(
|
||||
}
|
||||
if (command === "set_mcp_server_disabled") {
|
||||
const path = ensureMcpSettingsFile();
|
||||
const parsed = JSON.parse(readFileSync(path, "utf8")) as JsonRecord;
|
||||
const servers = (parsed.mcpServers as JsonRecord | undefined) ?? {};
|
||||
const name = String(args?.name ?? "").trim();
|
||||
const current = servers[name];
|
||||
if (!current || typeof current !== "object") {
|
||||
throw new Error(`unknown MCP server: ${name}`);
|
||||
}
|
||||
servers[name] = {
|
||||
...(current as JsonRecord),
|
||||
disabled: Boolean(args?.disabled),
|
||||
};
|
||||
writeMcpServersMap(servers);
|
||||
updateMcpSettingsFileSync(path, (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
|
||||
const name = String(args?.name ?? "").trim();
|
||||
const current = servers[name];
|
||||
if (!current || typeof current !== "object") {
|
||||
throw new Error(`unknown MCP server: ${name}`);
|
||||
}
|
||||
servers[name] = {
|
||||
...(current as JsonRecord),
|
||||
disabled: Boolean(args?.disabled),
|
||||
};
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
if (command === "upsert_mcp_server") {
|
||||
@@ -1093,21 +1093,23 @@ export async function handleCommand(
|
||||
metadata: input.metadata,
|
||||
};
|
||||
const path = ensureMcpSettingsFile();
|
||||
const parsed = JSON.parse(readFileSync(path, "utf8")) as JsonRecord;
|
||||
const servers = (parsed.mcpServers as JsonRecord | undefined) ?? {};
|
||||
if (previousName && previousName !== name) {
|
||||
delete servers[previousName];
|
||||
}
|
||||
servers[name] = next;
|
||||
writeMcpServersMap(servers);
|
||||
updateMcpSettingsFileSync(path, (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
|
||||
if (previousName && previousName !== name) {
|
||||
delete servers[previousName];
|
||||
}
|
||||
servers[name] = next;
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
if (command === "delete_mcp_server") {
|
||||
const path = ensureMcpSettingsFile();
|
||||
const parsed = JSON.parse(readFileSync(path, "utf8")) as JsonRecord;
|
||||
const servers = (parsed.mcpServers as JsonRecord | undefined) ?? {};
|
||||
delete servers[String(args?.name ?? "")];
|
||||
writeMcpServersMap(servers);
|
||||
updateMcpSettingsFileSync(path, (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
|
||||
delete servers[String(args?.name ?? "")];
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
if (command === "ensure_mcp_settings_file") {
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"extension": [
|
||||
"ts"
|
||||
],
|
||||
"spec": [
|
||||
"src/**/__tests__/*.ts",
|
||||
"src/test/services/**/*.test.ts"
|
||||
],
|
||||
"require": [
|
||||
"ts-node/register",
|
||||
"source-map-support/register",
|
||||
"./src/test/requires.ts"
|
||||
],
|
||||
"recursive": true,
|
||||
"exit": true
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"all": true,
|
||||
"check-coverage": false,
|
||||
"reporter": [
|
||||
"text",
|
||||
"lcov"
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"**/*.d.ts",
|
||||
|
||||
"**/*.{test,spec}.{js,jsx,ts,tsx,mjs,cjs}",
|
||||
"**/__tests__/**",
|
||||
"**/test/**",
|
||||
"**/tests/**",
|
||||
"**/.nyc_output/**",
|
||||
"**/.vscode-test/**",
|
||||
"**/tests-results/**",
|
||||
"src/test/**",
|
||||
|
||||
"src/generated/**",
|
||||
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"**/out/**",
|
||||
"**/build/**",
|
||||
"**/coverage/**",
|
||||
"**/coverage-unit/**",
|
||||
"**/proto/**",
|
||||
|
||||
"**/*.{config,setup}.{js,ts,mjs,cjs}",
|
||||
"**/vite-env.d.ts",
|
||||
|
||||
"**/*.{css,scss,sass,less,styl}",
|
||||
"**/*.{svg,png,jpg,jpeg,gif,ico}",
|
||||
"**/*.{json,yaml,yml}"
|
||||
],
|
||||
"extension": [
|
||||
".ts",
|
||||
".js"
|
||||
],
|
||||
"cache": true,
|
||||
"sourceMap": true,
|
||||
"instrument": true,
|
||||
"report-dir": "./coverage-unit"
|
||||
}
|
||||
@@ -1,9 +1,20 @@
|
||||
import { defineConfig } from "@vscode/test-cli"
|
||||
import path from "path"
|
||||
|
||||
const vscodeTestVersion = process.env.VSCODE_TEST_VERSION ?? "stable"
|
||||
|
||||
export default defineConfig({
|
||||
files: "{out/**/*.test.js,src/**/*.test.js,!src/test/e2e/**/*.test.js,!out/src/test/e2e/**/*.test.js}",
|
||||
files: [
|
||||
"out/src/{core,test,utils,shared,integrations,hosts,services}/**/*.test.js",
|
||||
"src/{core,test,utils,shared,integrations,hosts,services}/**/*.test.js",
|
||||
// The bun unit suite (src/**/__tests__/* and src/test/services/**) runs under
|
||||
// `bun test` (run-bun-unit-tests.ts) and imports `bun:test`, which this
|
||||
// Node-based runner cannot load. Exclude it here.
|
||||
"!out/src/**/__tests__/**/*.test.js",
|
||||
"!out/src/test/services/**/*.test.js",
|
||||
"!src/**/__tests__/**/*.test.js",
|
||||
"!src/test/services/**/*.test.js",
|
||||
],
|
||||
mocha: {
|
||||
ui: "bdd",
|
||||
timeout: 20000, // Maximum time (in ms) that a test can run before failing
|
||||
|
||||
@@ -5,13 +5,31 @@
|
||||
# Agent tooling, never shipped in the VSIX
|
||||
.agents/**
|
||||
.claude/**
|
||||
.cline/**
|
||||
.codex/**
|
||||
CLAUDE.local.md
|
||||
out/
|
||||
dist-standalone/
|
||||
node_modules/
|
||||
# Nested workspace-member node_modules (bun links these under each package).
|
||||
# Scoped to the sub-package dirs so it doesn't shadow the top-level
|
||||
# node_modules/@vscode/codicons re-include below.
|
||||
webview-ui/node_modules/**
|
||||
testing-platform/node_modules/**
|
||||
standalone/**/node_modules/**
|
||||
src/**
|
||||
standalone/**
|
||||
# Build/dev tooling and inputs — bundled into dist/extension.js, not needed in the VSIX.
|
||||
bunfig.toml
|
||||
esbuild.mjs
|
||||
knip.json
|
||||
biome.jsonc
|
||||
test-setup.js
|
||||
.env.example
|
||||
scripts/**
|
||||
proto/**
|
||||
testing-platform/**
|
||||
tests/**
|
||||
.gitignore
|
||||
.yarnrc
|
||||
esbuild.js
|
||||
@@ -34,6 +52,7 @@ sdk/**
|
||||
# only exists if a publish aborts mid-swap; neither should ship in the .vsix.
|
||||
README.marketplace.md
|
||||
.README.github.bak
|
||||
package.json.backup
|
||||
|
||||
# Custom
|
||||
**/demo.gif
|
||||
@@ -46,7 +65,6 @@ eslint-rules/
|
||||
old_docs/
|
||||
evals/
|
||||
.codespellrc
|
||||
.mocharc.json
|
||||
buf.yaml
|
||||
.clinerules/
|
||||
|
||||
@@ -78,6 +96,9 @@ old_docs/**
|
||||
e2e-build.mjs
|
||||
e2e.vsix
|
||||
test-results/
|
||||
coverage/**
|
||||
webview-ui/coverage/**
|
||||
webview-ui/.vite-port
|
||||
|
||||
# Ignore Storybook files
|
||||
**/*.stories.tsx
|
||||
|
||||
+21
-6
@@ -1,6 +1,11 @@
|
||||
{
|
||||
"root": false,
|
||||
"root": true,
|
||||
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"defaultBranch": "main"
|
||||
},
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": {
|
||||
@@ -124,14 +129,18 @@
|
||||
"!!**/playwright",
|
||||
"!!**/.vscode-test",
|
||||
"!!**/test-results",
|
||||
"!!**/coverage",
|
||||
"!!**/node_modules",
|
||||
"!!**/webview-ui/build",
|
||||
"!!**/generated",
|
||||
"!!**/proto",
|
||||
"!!**/tests/specs"
|
||||
"!!**/tests/specs",
|
||||
"!!assets/icons/*.svg"
|
||||
]
|
||||
},
|
||||
"plugins": ["src/dev/grit/process-env.grit"],
|
||||
"plugins": [
|
||||
"src/dev/grit/process-env.grit"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"includes": [
|
||||
@@ -146,11 +155,15 @@
|
||||
"!!src/integrations/terminal/**",
|
||||
"!!src/core/controller/ui/openWalkthrough.ts"
|
||||
],
|
||||
"plugins": ["src/dev/grit/vscode-api.grit"]
|
||||
"plugins": [
|
||||
"src/dev/grit/vscode-api.grit"
|
||||
]
|
||||
},
|
||||
{
|
||||
// Do not use console logging directly, use the Logger service instead.
|
||||
"plugins": ["src/dev/grit/console-log.grit"],
|
||||
"plugins": [
|
||||
"src/dev/grit/console-log.grit"
|
||||
],
|
||||
"includes": [
|
||||
"**",
|
||||
"!!**/esbuild.*",
|
||||
@@ -183,7 +196,9 @@
|
||||
"!!src/core/storage/utils/state-helpers.ts",
|
||||
"!!src/extension.ts"
|
||||
],
|
||||
"plugins": ["src/dev/grit/use-cache-service.grit"]
|
||||
"plugins": [
|
||||
"src/dev/grit/use-cache-service.grit"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
[test]
|
||||
# Module-substitution aliases for `bun test`. bun resolves tsconfig `paths`
|
||||
# (@/*, @core/*, @shared/*, …) and the real @cline/llms + @cline/shared dist
|
||||
# builds on its own; the preload only shadows `vscode` and `@cline/core` with
|
||||
# their unit-test stubs (mirrors vitest.config.ts resolve.alias). See
|
||||
# src/test/bun-test-preload.ts for details.
|
||||
preload = ["./src/test/bun-test-preload.ts"]
|
||||
@@ -85,44 +85,6 @@ const esbuildProblemMatcherPlugin = {
|
||||
},
|
||||
}
|
||||
|
||||
const copyWasmFiles = {
|
||||
name: "copy-wasm-files",
|
||||
setup(build) {
|
||||
build.onEnd(() => {
|
||||
// tree sitter
|
||||
const sourceDir = path.join(__dirname, "node_modules", "web-tree-sitter")
|
||||
const targetDir = path.join(__dirname, destDir)
|
||||
|
||||
// Copy tree-sitter.wasm
|
||||
fs.copyFileSync(path.join(sourceDir, "tree-sitter.wasm"), path.join(targetDir, "tree-sitter.wasm"))
|
||||
|
||||
// Copy language-specific WASM files
|
||||
const languageWasmDir = path.join(__dirname, "node_modules", "tree-sitter-wasms", "out")
|
||||
const languages = [
|
||||
"typescript",
|
||||
"tsx",
|
||||
"python",
|
||||
"rust",
|
||||
"javascript",
|
||||
"go",
|
||||
"cpp",
|
||||
"c",
|
||||
"c_sharp",
|
||||
"ruby",
|
||||
"java",
|
||||
"php",
|
||||
"swift",
|
||||
"kotlin",
|
||||
]
|
||||
|
||||
languages.forEach((lang) => {
|
||||
const filename = `tree-sitter-${lang}.wasm`
|
||||
fs.copyFileSync(path.join(languageWasmDir, filename), path.join(targetDir, filename))
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const buildEnvVars = {
|
||||
"import.meta.url": "_importMetaUrl",
|
||||
"process.env.IS_STANDALONE": JSON.stringify(standalone ? "true" : "false"),
|
||||
@@ -176,7 +138,6 @@ const baseConfig = {
|
||||
define: buildEnvVars,
|
||||
tsconfig: path.resolve(__dirname, "tsconfig.json"),
|
||||
plugins: [
|
||||
copyWasmFiles,
|
||||
aliasResolverPlugin,
|
||||
/* add to the end of plugins array */
|
||||
esbuildProblemMatcherPlugin,
|
||||
|
||||
+32
-21
@@ -1,23 +1,34 @@
|
||||
{
|
||||
"entry": [
|
||||
"src/extension.ts",
|
||||
"src/standalone/cline-core.ts",
|
||||
"src/generated/hosts/standalone/protobus-server-setup.ts",
|
||||
"src/generated/hosts/standalone/host-bridge-clients.ts",
|
||||
"src/generated/hosts/vscode/protobus-services.ts",
|
||||
"src/generated/hosts/vscode/hostbridge-grpc-service-config.ts"
|
||||
],
|
||||
"project": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"ignore": [
|
||||
"out/**",
|
||||
"node_modules/**",
|
||||
"*.d.ts",
|
||||
"**/*.test.ts",
|
||||
"**/__tests__",
|
||||
"src/test/**",
|
||||
"src/shared/**"
|
||||
],
|
||||
"vite": true
|
||||
"$schema": "https://unpkg.com/knip@5/schema.json",
|
||||
"workspaces": {
|
||||
".": {
|
||||
"entry": [
|
||||
"src/extension.ts",
|
||||
"src/standalone/cline-core.ts",
|
||||
"src/generated/hosts/standalone/protobus-server-setup.ts",
|
||||
"src/generated/hosts/standalone/host-bridge-clients.ts",
|
||||
"src/generated/hosts/vscode/protobus-services.ts",
|
||||
"src/generated/hosts/vscode/hostbridge-grpc-service-config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/__tests__/**/*.ts",
|
||||
"src/test/**/*.ts"
|
||||
],
|
||||
"project": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
},
|
||||
"webview-ui": {
|
||||
"entry": [
|
||||
"src/services/grpc-client.ts",
|
||||
"src/**/*.test.{ts,tsx}",
|
||||
"src/**/*.spec.{ts,tsx}",
|
||||
"src/**/__tests__/**/*.{ts,tsx}"
|
||||
],
|
||||
"project": [
|
||||
"src/**/*.{ts,tsx}",
|
||||
"*.ts"
|
||||
],
|
||||
"vite": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
-21850
File diff suppressed because it is too large
Load Diff
+67
-109
@@ -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": "4.0.4",
|
||||
"version": "3.89.0",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
@@ -89,7 +89,7 @@
|
||||
{
|
||||
"id": "mcp",
|
||||
"title": "Extend with Powerful Tools (MCP)",
|
||||
"description": "Connect to databases, APIs, or discover new capabilities in the MCP Marketplace.",
|
||||
"description": "Connect to databases, APIs, and other external tools through MCP.",
|
||||
"media": {
|
||||
"markdown": "walkthrough/step4.md"
|
||||
}
|
||||
@@ -229,26 +229,9 @@
|
||||
"command": "cline.reconstructTaskHistory",
|
||||
"title": "Reconstruct Task History",
|
||||
"category": "Cline"
|
||||
},
|
||||
{
|
||||
"command": "cline.reviewComment.reply",
|
||||
"title": "Reply",
|
||||
"category": "Cline",
|
||||
"enablement": "!commentIsEmpty"
|
||||
},
|
||||
{
|
||||
"command": "cline.reviewComment.addToChat",
|
||||
"title": "Add to Cline Chat",
|
||||
"category": "Cline",
|
||||
"icon": "$(link-external)"
|
||||
}
|
||||
],
|
||||
"keybindings": [
|
||||
{
|
||||
"command": "editor.action.submitComment",
|
||||
"key": "enter",
|
||||
"when": "commentEditorFocused && commentController == cline-ai-review && !commentIsEmpty"
|
||||
},
|
||||
{
|
||||
"command": "cline.addToChat",
|
||||
"key": "cmd+'",
|
||||
@@ -350,24 +333,6 @@
|
||||
{
|
||||
"command": "cline.abortGitCommitMessage",
|
||||
"when": "config.git.enabled && cline.isGeneratingCommit"
|
||||
},
|
||||
{
|
||||
"command": "cline.reviewComment.reply",
|
||||
"when": "false"
|
||||
}
|
||||
],
|
||||
"comments/commentThread/context": [
|
||||
{
|
||||
"command": "cline.reviewComment.reply",
|
||||
"group": "inline",
|
||||
"when": "commentController == cline-ai-review"
|
||||
}
|
||||
],
|
||||
"comments/commentThread/title": [
|
||||
{
|
||||
"command": "cline.reviewComment.addToChat",
|
||||
"group": "inline",
|
||||
"when": "commentController == cline-ai-review"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -377,67 +342,74 @@
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"vscode:prepublish": "npm run package",
|
||||
"compile": "npm run check-types && npm run lint && node esbuild.mjs",
|
||||
"compile-standalone": "npm run check-types && npm run lint && node esbuild.mjs --standalone",
|
||||
"vscode:prepublish": "bun run package",
|
||||
"compile": "bun run check-types && bun run lint && bun esbuild.mjs",
|
||||
"compile-standalone": "bun run check-types && bun run lint && bun esbuild.mjs --standalone",
|
||||
"postcompile-standalone": "node scripts/package-standalone.mjs",
|
||||
"dev": "npm run protos && npm run watch",
|
||||
"watch": "npx npm-run-all -p watch:*",
|
||||
"watch:esbuild": "node esbuild.mjs --watch",
|
||||
"dev": "bun run protos && bun run watch",
|
||||
"watch": "bun run --parallel watch:esbuild watch:tsc",
|
||||
"watch:esbuild": "bun esbuild.mjs --watch",
|
||||
"watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
|
||||
"package": "npm run check-types && npm run build:webview && npm run lint && node esbuild.mjs --production",
|
||||
"package": "bun run check-types && bun run build:webview && bun run lint && bun esbuild.mjs --production",
|
||||
"protos": "node scripts/build-proto.mjs",
|
||||
"models": "node scripts/generate-models-dev-catalog.mjs",
|
||||
"protos-python": "node scripts/build-python-proto.mjs",
|
||||
"download-ripgrep": "node scripts/download-ripgrep.mjs",
|
||||
"postprotos": "biome format --config-path ./biome.jsonc src/shared/proto src/generated webview-ui/src/services/grpc-client.ts --write --no-errors-on-unmatched",
|
||||
"postprotos": "biome format --config-path ./biome.jsonc src/shared/proto src/core/controller src/hosts/ webview-ui/src/services src/generated --write --no-errors-on-unmatched",
|
||||
"clean:build": "rimraf dist dist-standalone webview-ui/build src/generated out/",
|
||||
"clean:deps": "rimraf node_modules webview-ui/node_modules",
|
||||
"clean:all": "npm run clean:build && npm run clean:deps",
|
||||
"clean:all": "bun run clean:build && bun run clean:deps",
|
||||
"compile-tests": "node ./scripts/build-tests.js",
|
||||
"watch-tests": "tsc -p . -w --outDir out",
|
||||
"check-types": "npm run protos && tsc --noEmit && cd webview-ui && tsc --noEmit",
|
||||
"lint": "biome lint --config-path ./biome.jsonc --no-errors-on-unmatched --files-ignore-unknown=true --diagnostic-level=error && npm run lint:proto",
|
||||
"check-types": "bun run protos && bunx tsc --noEmit && cd webview-ui && bunx tsc --noEmit",
|
||||
"lint": "biome lint --config-path ./biome.jsonc --no-errors-on-unmatched --files-ignore-unknown=true --diagnostic-level=error && bun run lint:proto",
|
||||
"lint:proto": "bash ./scripts/proto-lint.sh",
|
||||
"format": "biome format --config-path ./biome.jsonc --changed --since main --no-errors-on-unmatched --files-ignore-unknown=true --diagnostic-level=error",
|
||||
"format:fix": "biome check --config-path ./biome.jsonc --changed --since main --no-errors-on-unmatched --files-ignore-unknown=true --write",
|
||||
"fix:all": "biome check --config-path ./biome.jsonc --no-errors-on-unmatched --files-ignore-unknown=true --write --diagnostic-level=error --unsafe",
|
||||
"ci:check-all": "npx npm-run-all -p check-types lint format",
|
||||
"ci:build": "npm run protos && npm run build:webview && node esbuild.mjs && npm run compile-tests",
|
||||
"pretest": "npm run compile && npm run compile-tests && npm run compile-standalone && npm run lint",
|
||||
"test": "npx npm-run-all test:unit test:integration",
|
||||
"test:integration": "vscode-test",
|
||||
"test:unit": "cross-env TS_NODE_PROJECT=./tsconfig.unit-test.json mocha",
|
||||
"test:coverage": "vscode-test --coverage",
|
||||
"test:sca-server": "npx tsx watch scripts/test-standalone-core-api-server.ts",
|
||||
"test:tp-orchestrator": "npx tsx scripts/testing-platform-orchestrator.ts",
|
||||
"analyze:unused": "bunx --yes knip --config knip.json --include files,exports,types,enumMembers,duplicates",
|
||||
"analyze:unused:prod": "bunx --yes knip --config knip.json --production --include files,exports,types,enumMembers,dependencies",
|
||||
"analyze:unused:fix-exports": "node scripts/remove-unused-exports.mjs --apply",
|
||||
"analyze:unused:fix-exports:dry": "node scripts/remove-unused-exports.mjs",
|
||||
"format": "biome format --config-path ./biome.jsonc --changed --since main --no-errors-on-unmatched --files-ignore-unknown=true --diagnostic-level=error --semicolons=as-needed",
|
||||
"format:fix": "biome check --config-path ./biome.jsonc --changed --since main --no-errors-on-unmatched --files-ignore-unknown=true --write --semicolons=as-needed",
|
||||
"fix:all": "biome check --config-path ./biome.jsonc --no-errors-on-unmatched --files-ignore-unknown=true --write --diagnostic-level=error --unsafe --semicolons=as-needed",
|
||||
"ci:check-all": "bun run --parallel check-types lint format",
|
||||
"ci:build": "bun run protos && bun run build:webview && bun esbuild.mjs && bun run compile-tests",
|
||||
"pretest": "bun run compile && bun run compile-tests && bun run compile-standalone && bun run lint",
|
||||
"test": "bun run test:unit && bun run test:integration",
|
||||
"test:integration": "bun run compile-tests && vscode-test",
|
||||
"test:unit": "bun scripts/run-bun-unit-tests.ts",
|
||||
"test:vitest": "vitest run --config vitest.config.ts",
|
||||
"test:vitest:watch": "vitest --config vitest.config.ts",
|
||||
"test:bun": "bun scripts/run-bun-tests.ts",
|
||||
"test:bun:unit": "bun scripts/run-bun-unit-tests.ts",
|
||||
"test:coverage": "bun run compile-tests && vscode-test --coverage",
|
||||
"test:sca-server": "bun --watch scripts/test-standalone-core-api-server.ts",
|
||||
"test:tp-orchestrator": "bun scripts/testing-platform-orchestrator.ts",
|
||||
"dev:mcp-oauth-test-server": "bun src/dev/mcp-oauth-test-server/server.ts",
|
||||
"e2e": "playwright test -c playwright.config.ts",
|
||||
"test:e2e:build": "vsce package --allow-package-secrets sendgrid --out dist/e2e.vsix",
|
||||
"test:e2e": "playwright install && npm run test:e2e:build && node src/test/e2e/utils/build.mjs && playwright test",
|
||||
"test:e2e:optimal": "npm run test:e2e:build && node src/test/e2e/utils/build.mjs && playwright test",
|
||||
"test:e2e:ui": "npx tsx scripts/interactive-playwright.ts",
|
||||
"install:all": "npm install && cd webview-ui && npm install",
|
||||
"dev:webview": "cd webview-ui && npm run dev",
|
||||
"build:webview": "cd webview-ui && npm run build",
|
||||
"test:webview": "cd webview-ui && npm run test",
|
||||
"test:e2e:build": "vsce package --no-dependencies --allow-package-secrets sendgrid --out dist/e2e.vsix",
|
||||
"test:e2e": "playwright install && bun run test:e2e:build && node src/test/e2e/utils/build.mjs && playwright test",
|
||||
"test:e2e:optimal": "bun run test:e2e:build && node src/test/e2e/utils/build.mjs && playwright test",
|
||||
"test:e2e:ui": "bun scripts/interactive-playwright.ts",
|
||||
"install:all": "bun install",
|
||||
"dev:webview": "cd webview-ui && bun run dev",
|
||||
"build:webview": "cd webview-ui && bun run build",
|
||||
"test:webview": "cd webview-ui && bun run test",
|
||||
"publish:marketplace": "node scripts/publish-marketplace.mjs",
|
||||
"publish:marketplace:prerelease": "node scripts/publish-marketplace.mjs --pre-release",
|
||||
"publish:marketplace:nightly": "node ./scripts/publish-nightly.mjs",
|
||||
"prepare": "npx husky",
|
||||
"docs": "cd docs && npm run dev",
|
||||
"docs:check-links": "cd docs && npm run check",
|
||||
"docs:rename-file": "cd docs && npm run rename",
|
||||
"docs": "cd docs && bun run dev",
|
||||
"docs:check-links": "cd docs && bun run check",
|
||||
"docs:rename-file": "cd docs && bun run rename",
|
||||
"report-issue": "node scripts/report-issue.js",
|
||||
"storybook": "cd webview-ui && npm run storybook",
|
||||
"eval:smoke:run": "npx tsx evals/smoke-tests/run-smoke-tests.ts"
|
||||
"storybook": "cd webview-ui && bun run storybook",
|
||||
"eval:smoke:run": "bun evals/smoke-tests/run-smoke-tests.ts"
|
||||
},
|
||||
"lint-staged": {
|
||||
"src/shared/storage/state-keys.ts": [
|
||||
"node scripts/generate-state-proto.mjs",
|
||||
"git add proto/cline/state.proto"
|
||||
"git add apps/vscode/proto/cline/state.proto"
|
||||
],
|
||||
"*": [
|
||||
"biome check --write --staged --no-errors-on-unmatched --files-ignore-unknown=true"
|
||||
"biome check --write --staged --no-errors-on-unmatched --files-ignore-unknown=true --semicolons=as-needed"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -453,7 +425,6 @@
|
||||
"@types/node": "20.x",
|
||||
"@types/pdf-parse": "^1.1.4",
|
||||
"@types/picomatch": "^4.0.2",
|
||||
"@types/proxyquire": "^1.3.31",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/should": "^11.2.0",
|
||||
"@types/sinon": "^21.0.0",
|
||||
@@ -465,42 +436,41 @@
|
||||
"c8": "^10.1.3",
|
||||
"chai": "^4.3.10",
|
||||
"chalk": "5.6.2",
|
||||
"cross-env": "^10.1.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"esbuild": "^0.25.0",
|
||||
"glob": "^11.0.0",
|
||||
"grpc-tools": "^1.13.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.1.0",
|
||||
"minimatch": "^3.0.3",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"nyc": "^17.1.0",
|
||||
"minimist": "^1.2.8",
|
||||
"mocha": "^11.7.4",
|
||||
"playwright": "^1.55.1",
|
||||
"prebuild-install": "^7.1.3",
|
||||
"protoc-gen-ts": "^0.8.7",
|
||||
"proxyquire": "^2.1.3",
|
||||
"rimraf": "^6.0.1",
|
||||
"should": "^13.2.3",
|
||||
"sinon": "^21.0.3",
|
||||
"tar": "^7.5.2",
|
||||
"tree-kill": "^1.2.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"ts-proto": "^2.6.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.4.5"
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "^4.0.17"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.50.4",
|
||||
"@anthropic-ai/vertex-sdk": "^0.11.5",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.922.0",
|
||||
"@aws-sdk/credential-providers": "^3.922.0",
|
||||
"@azure/identity": "^4.13.0",
|
||||
"@anthropic-ai/sdk": "^0.37.0",
|
||||
"@bufbuild/protobuf": "^2.2.5",
|
||||
"@cerebras/cerebras_cloud_sdk": "^1.35.0",
|
||||
"@google-cloud/vertexai": "^1.9.3",
|
||||
"@cline/agents": "workspace:*",
|
||||
"@cline/core": "workspace:*",
|
||||
"@cline/llms": "workspace:*",
|
||||
"@cline/shared": "workspace:*",
|
||||
"@google/genai": "^1.30.0",
|
||||
"@grpc/grpc-js": "^1.9.15",
|
||||
"@grpc/proto-loader": "^0.7.13",
|
||||
"@grpc/reflection": "^1.0.4",
|
||||
"@mistralai/mistralai": "^1.5.0",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/api-logs": "^0.56.0",
|
||||
"@opentelemetry/core": "^2.1.0",
|
||||
"@opentelemetry/exporter-logs-otlp-grpc": "^0.56.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.56.0",
|
||||
@@ -520,9 +490,6 @@
|
||||
"@opentelemetry/sdk-trace-node": "^1.30.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.37.0",
|
||||
"@playwright/test": "^1.55.1",
|
||||
"@sap-ai-sdk/ai-api": "^2.7.0",
|
||||
"@sap-ai-sdk/orchestration": "^2.7.0",
|
||||
"@sap-cloud-sdk/connectivity": "^4.6.0",
|
||||
"@streamparser/json": "^0.0.22",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@types/uuid": "^10.0.0",
|
||||
@@ -548,13 +515,15 @@
|
||||
"ignore": "^7.0.3",
|
||||
"image-size": "^2.0.2",
|
||||
"isbinaryfile": "^5.0.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jschardet": "^3.1.4",
|
||||
"json5": "^2.2.3",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"nanoid": "^5.1.6",
|
||||
"nice-grpc": "^2.1.12",
|
||||
"nice-grpc-common": "^2.0.3",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"ollama": "^0.5.13",
|
||||
"open": "^10.1.2",
|
||||
"open-graph-scraper": "^6.9.0",
|
||||
"openai": "^6.21.0",
|
||||
@@ -573,24 +542,13 @@
|
||||
"simple-git": "3.36.0",
|
||||
"strip-ansi": "^7.1.2",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tree-sitter-wasms": "^0.1.11",
|
||||
"ts-morph": "^25.0.1",
|
||||
"turndown": "^7.2.0",
|
||||
"ulid": "^2.4.0",
|
||||
"undici": "^7.26.0",
|
||||
"uuid": "^11.1.0",
|
||||
"vscode-uri": "^3.1.0",
|
||||
"web-tree-sitter": "^0.22.6",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"overrides": {
|
||||
"tar-fs": ">=3.1.1",
|
||||
"tar": "^7.5.2",
|
||||
"vite": "^7.1.11",
|
||||
"js-yaml": "^4.1.1",
|
||||
"serialize-javascript": ">=7.0.3",
|
||||
"protobufjs": "7.5.8",
|
||||
"diff": "8.0.4"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"c8": {
|
||||
"reporter": [
|
||||
|
||||
@@ -12,16 +12,11 @@ service McpService {
|
||||
rpc toggleMcpServer(ToggleMcpServerRequest) returns (McpServers);
|
||||
rpc updateMcpTimeout(UpdateMcpTimeoutRequest) returns (McpServers);
|
||||
rpc addRemoteMcpServer(AddRemoteMcpServerRequest) returns (McpServers);
|
||||
rpc downloadMcp(StringRequest) returns (McpDownloadResponse);
|
||||
rpc restartMcpServer(StringRequest) returns (McpServers);
|
||||
rpc deleteMcpServer(StringRequest) returns (McpServers);
|
||||
rpc toggleToolAutoApprove(ToggleToolAutoApproveRequest) returns (McpServers);
|
||||
rpc refreshMcpMarketplace(EmptyRequest) returns (McpMarketplaceCatalog);
|
||||
rpc openMcpSettings(EmptyRequest) returns (Empty);
|
||||
rpc authenticateMcpServer(StringRequest) returns (Empty);
|
||||
|
||||
// Subscribe to MCP marketplace catalog updates
|
||||
rpc subscribeToMcpMarketplaceCatalog(EmptyRequest) returns (stream McpMarketplaceCatalog);
|
||||
rpc getLatestMcpServers(Empty) returns (McpServers);
|
||||
|
||||
// Subscribe to MCP server updates
|
||||
@@ -114,40 +109,3 @@ message McpServer {
|
||||
message McpServers {
|
||||
repeated McpServer mcp_servers = 1;
|
||||
}
|
||||
|
||||
message McpMarketplaceItem {
|
||||
string mcp_id = 1;
|
||||
string github_url = 2;
|
||||
string name = 3;
|
||||
string author = 4;
|
||||
string description = 5;
|
||||
string codicon_icon = 6;
|
||||
string logo_url = 7;
|
||||
string category = 8;
|
||||
repeated string tags = 9;
|
||||
bool requires_api_key = 10;
|
||||
optional string readme_content = 11;
|
||||
optional string llms_installation_content = 12;
|
||||
bool is_recommended = 13;
|
||||
int32 github_stars = 14;
|
||||
int32 download_count = 15;
|
||||
string created_at = 16;
|
||||
string updated_at = 17;
|
||||
string last_github_sync = 18;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -21,8 +21,6 @@ service ModelsService {
|
||||
rpc refreshOpenRouterModelsRpc(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
|
||||
// Refreshes and returns recommended and free Cline models
|
||||
rpc refreshClineRecommendedModelsRpc(EmptyRequest) returns (ClineRecommendedModelsResponse);
|
||||
// Refreshes and returns Cline provider models
|
||||
rpc refreshClineModelsRpc(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
|
||||
// Refreshes and returns Hugging Face models
|
||||
rpc refreshHuggingFaceModels(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
|
||||
// Refreshes and returns OpenAI models
|
||||
@@ -55,6 +53,18 @@ service ModelsService {
|
||||
rpc getAihubmixModels(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
|
||||
// Refreshes and returns Vercel AI Gateway models
|
||||
rpc refreshVercelAiGatewayModelsRpc(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
|
||||
// Lists providers available from the unified SDK-backed catalog
|
||||
rpc listProviders(Empty) returns (ProviderListingsResponse);
|
||||
// Resolves model metadata for a provider through the unified SDK-backed catalog
|
||||
rpc resolveProviderModels(ResolveProviderModelsRequest) returns (ProviderModelsResponse);
|
||||
// Resolves model metadata for a provider/model without refreshing model lists
|
||||
rpc resolveModelInfo(ResolveModelInfoRequest) returns (ResolveModelInfoResponse);
|
||||
// Reads redacted effective provider configuration
|
||||
rpc readProviderConfig(StringRequest) returns (ProviderConfigResponse);
|
||||
// Writes provider configuration fields and returns redacted effective configuration
|
||||
rpc writeProviderConfig(WriteProviderConfigRequest) returns (ProviderConfigResponse);
|
||||
// Commits a mode-specific model selection atomically with its model metadata
|
||||
rpc commitModelSelection(CommitModelSelectionRequest) returns (Empty);
|
||||
}
|
||||
|
||||
// List of VS Code LM models
|
||||
@@ -117,6 +127,142 @@ message OpenRouterCompatibleModelInfo {
|
||||
map<string, OpenRouterModelInfo> models = 1;
|
||||
}
|
||||
|
||||
// Lightweight provider entry for the top-level model/provider picker.
|
||||
// Does not include the full model list; use resolveProviderModels for models.
|
||||
message ProviderListing {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
optional string default_model_id = 3;
|
||||
optional string family = 4;
|
||||
optional string protocol = 5;
|
||||
optional string auth_description = 6;
|
||||
optional string base_url_description = 7;
|
||||
bool allows_custom_model_ids = 8;
|
||||
|
||||
// SDK-driven hint for cost display. Values: "show" (default) or "hide".
|
||||
// Sourced from `resolveProviderUsageCostDisplay(provider.metadata)` in
|
||||
// `@cline/llms`. When "hide", consumers must suppress per-token pricing
|
||||
// and total cost displays (matches the CLI's `shouldShowCliUsageCost`).
|
||||
string usage_cost_display = 11;
|
||||
}
|
||||
|
||||
message ProviderListingsResponse {
|
||||
repeated ProviderListing providers = 1;
|
||||
}
|
||||
|
||||
message ResolveProviderModelsRequest {
|
||||
string provider_id = 1;
|
||||
bool force_refresh = 2;
|
||||
optional string request_id = 3;
|
||||
}
|
||||
|
||||
message CatalogErrorInfo {
|
||||
string kind = 1;
|
||||
string message = 2;
|
||||
optional string code = 3;
|
||||
optional bool retryable = 4;
|
||||
}
|
||||
|
||||
message ProviderModelsResponse {
|
||||
string provider_id = 1;
|
||||
string request_id = 2;
|
||||
string config_fingerprint = 3;
|
||||
int64 fetched_at = 4;
|
||||
bool ok = 5;
|
||||
map<string, OpenRouterModelInfo> models = 6;
|
||||
optional string default_model_id = 7;
|
||||
optional string source = 8;
|
||||
optional CatalogErrorInfo error = 9;
|
||||
}
|
||||
|
||||
message ResolveModelInfoRequest {
|
||||
string provider_id = 1;
|
||||
optional string model_id = 2;
|
||||
}
|
||||
|
||||
message ResolveModelInfoResponse {
|
||||
string provider_id = 1;
|
||||
string model_id = 2;
|
||||
optional OpenRouterModelInfo model_info = 3;
|
||||
string source = 4;
|
||||
}
|
||||
|
||||
message AwsProviderConfig {
|
||||
optional string authentication = 1;
|
||||
optional string profile = 2;
|
||||
optional string access_key = 3;
|
||||
int64 access_key_length = 4;
|
||||
optional string secret_key = 5;
|
||||
int64 secret_key_length = 6;
|
||||
optional string session_token = 7;
|
||||
int64 session_token_length = 8;
|
||||
optional string endpoint = 9;
|
||||
optional bool use_prompt_cache = 10;
|
||||
optional string custom_model_base_id = 11;
|
||||
optional bool use_cross_region_inference = 12;
|
||||
optional bool use_global_inference = 13;
|
||||
}
|
||||
|
||||
message GcpProviderConfig {
|
||||
optional string project_id = 1;
|
||||
optional string region = 2;
|
||||
}
|
||||
|
||||
message ProviderConfigResponse {
|
||||
string provider_id = 1;
|
||||
optional string base_url = 2;
|
||||
optional string api_line = 3;
|
||||
map<string, string> headers = 4;
|
||||
optional string region = 5;
|
||||
int64 api_key_length = 6;
|
||||
bool has_access_token = 7;
|
||||
bool has_refresh_token = 8;
|
||||
optional string account_id = 9;
|
||||
optional CommittedModelSelection plan_selection = 10;
|
||||
optional CommittedModelSelection act_selection = 11;
|
||||
optional AwsProviderConfig aws = 12;
|
||||
optional GcpProviderConfig gcp = 13;
|
||||
}
|
||||
|
||||
message CommittedModelSelection {
|
||||
string provider_id = 1;
|
||||
string model_id = 2;
|
||||
OpenRouterModelInfo model_info = 3;
|
||||
}
|
||||
|
||||
message ProviderReasoningPatch {
|
||||
optional bool enabled = 1;
|
||||
optional string effort = 2; // "none" | "low" | "medium" | "high" | "xhigh"
|
||||
optional int32 budget_tokens = 3;
|
||||
}
|
||||
|
||||
message WriteProviderConfigPatch {
|
||||
optional string api_key = 1;
|
||||
optional string base_url = 2;
|
||||
map<string, string> headers = 3;
|
||||
optional string region = 4;
|
||||
optional string api_line = 5;
|
||||
optional string access_token = 6;
|
||||
optional string refresh_token = 7;
|
||||
optional string account_id = 8;
|
||||
optional ProviderReasoningPatch reasoning = 9;
|
||||
optional bool clear_headers = 10;
|
||||
optional AwsProviderConfig aws = 11;
|
||||
optional GcpProviderConfig gcp = 12;
|
||||
}
|
||||
|
||||
message WriteProviderConfigRequest {
|
||||
string provider_id = 1;
|
||||
WriteProviderConfigPatch patch = 2;
|
||||
}
|
||||
|
||||
message CommitModelSelectionRequest {
|
||||
string provider_id = 1;
|
||||
string mode = 2;
|
||||
string model_id = 3;
|
||||
OpenRouterModelInfo model_info = 4;
|
||||
}
|
||||
|
||||
message ClineRecommendedModel {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
@@ -467,6 +613,12 @@ enum ApiProvider {
|
||||
OPENAI_CODEX = 40;
|
||||
WANDB = 41;
|
||||
CLINE_PASS = 42;
|
||||
POOLSIDE = 45;
|
||||
V0 = 46;
|
||||
XIAOMI = 47;
|
||||
ZAI_CODING_PLAN = 49;
|
||||
reserved 43, 44, 48;
|
||||
reserved "OPENAI_CODEX_CLI", "OPENCODE", "KILO";
|
||||
}
|
||||
|
||||
enum ApiFormat {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
|
||||
import "cline/common.proto";
|
||||
|
||||
option go_package = "github.com/cline/grpc-go/cline";
|
||||
option java_multiple_files = true;
|
||||
option java_package = "bot.cline.proto";
|
||||
|
||||
enum RemoteConfigType {
|
||||
RULE = 0;
|
||||
WORKFLOW = 1;
|
||||
SKILL = 2;
|
||||
}
|
||||
|
||||
message RemoteConfigSetting {
|
||||
RemoteConfigType type = 1;
|
||||
string name = 2;
|
||||
string content = 3;
|
||||
bool enabled = 4;
|
||||
bool locked = 5;
|
||||
}
|
||||
|
||||
message RemoteConfigSettingsResponse {
|
||||
repeated RemoteConfigSetting settings = 1;
|
||||
}
|
||||
|
||||
service RemoteConfigService {
|
||||
rpc getRemoteConfigSettings(Empty) returns (RemoteConfigSettingsResponse);
|
||||
rpc toggleRemoteConfigSetting(StringRequest) returns (RemoteConfigSetting);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ message SlashCommandInfo {
|
||||
string name = 1; // Command name without slash, e.g., "newtask", "smol"
|
||||
string description = 2; // Human-readable description
|
||||
string section = 3; // "default", "custom", or "cli"
|
||||
bool cli_compatible = 4; // false for VS Code-only commands like explain-changes
|
||||
bool cli_compatible = 4; // false for VS Code-only commands
|
||||
}
|
||||
|
||||
// Response containing all available slash commands
|
||||
|
||||
@@ -250,7 +250,6 @@ message Settings {
|
||||
optional string default_terminal_profile = 137;
|
||||
optional int32 terminal_output_line_limit = 138;
|
||||
optional int32 max_consecutive_mistakes = 139;
|
||||
optional bool strict_plan_mode_enabled = 141;
|
||||
optional bool yolo_mode_toggled = 142;
|
||||
optional bool use_auto_condense = 143;
|
||||
optional bool cline_web_tools_enabled = 144;
|
||||
@@ -286,7 +285,6 @@ message Settings {
|
||||
optional string act_mode_cline_model_id = 180;
|
||||
optional OpenRouterModelInfo act_mode_cline_model_info = 181;
|
||||
optional bool show_feature_tips = 182;
|
||||
optional bool lazy_teammate_mode_enabled = 183;
|
||||
}
|
||||
|
||||
message State {
|
||||
@@ -391,6 +389,7 @@ message UpdateSettingsRequest {
|
||||
reserved 15; // was openai_reasoning_effort (moved to mode-scoped reasoning effort)
|
||||
reserved 23; // was dictation_settings (dictation removed)
|
||||
reserved 38; // was skills_enabled (removed - now always enabled)
|
||||
reserved 43; // was lazy_teammate_mode_enabled (removed)
|
||||
|
||||
Metadata metadata = 1;
|
||||
optional ModelsApiConfiguration api_configuration = 2;
|
||||
@@ -405,7 +404,7 @@ message UpdateSettingsRequest {
|
||||
optional int32 terminal_output_line_limit = 12;
|
||||
optional PlanActMode mode = 13;
|
||||
optional string preferred_language = 14;
|
||||
optional bool strict_plan_mode_enabled = 16;
|
||||
reserved 16; // was strict_plan_mode_enabled (removed)
|
||||
optional FocusChainSettings focus_chain_settings = 17;
|
||||
optional bool use_auto_condense = 18;
|
||||
optional string custom_prompt = 19;
|
||||
@@ -429,7 +428,6 @@ message UpdateSettingsRequest {
|
||||
optional bool worktrees_enabled = 40;
|
||||
optional bool double_check_completion_enabled = 41;
|
||||
optional bool show_feature_tips = 42;
|
||||
optional bool lazy_teammate_mode_enabled = 43;
|
||||
}
|
||||
|
||||
message UpdateTerminalConnectionTimeoutRequest {
|
||||
@@ -456,6 +454,11 @@ message OnboardingProgressRequest {
|
||||
optional string action = 2;
|
||||
optional bool completed = 3;
|
||||
optional string model_selected = 4;
|
||||
optional string page = 5;
|
||||
optional string page_variant = 6;
|
||||
optional string user_type = 7;
|
||||
optional int32 destination_step = 8;
|
||||
optional string destination_page = 9;
|
||||
}
|
||||
|
||||
message OnboardingModelGroup {
|
||||
|
||||
@@ -32,6 +32,8 @@ service TaskService {
|
||||
rpc getTaskHistory(GetTaskHistoryRequest) returns (TaskHistoryArray);
|
||||
// Sends a response to a previous ask operation
|
||||
rpc askResponse(AskResponseRequest) returns (Empty);
|
||||
// Edits a previous user message, truncates following conversation, and regenerates
|
||||
rpc editMessageAndRegenerate(EditMessageAndRegenerateRequest) returns (Empty);
|
||||
// Records task feedback (thumbs up/down)
|
||||
rpc taskFeedback(StringRequest) returns (Empty);
|
||||
// Shows task completion changes diff in a view
|
||||
@@ -40,8 +42,6 @@ service TaskService {
|
||||
rpc executeQuickWin(ExecuteQuickWinRequest) returns (Empty);
|
||||
// Deletes all task history
|
||||
rpc deleteAllTaskHistory(EmptyRequest) returns (DeleteAllTaskHistoryCount);
|
||||
// Explains changes with AI and adds inline comments to the diff view
|
||||
rpc explainChanges(ExplainChangesRequest) returns (Empty);
|
||||
}
|
||||
|
||||
// Request message for creating a new task
|
||||
@@ -82,12 +82,14 @@ message GetTaskHistoryRequest {
|
||||
string search_query = 3;
|
||||
string sort_by = 4;
|
||||
bool current_workspace_only = 5;
|
||||
int32 limit = 6;
|
||||
int32 offset = 7;
|
||||
}
|
||||
|
||||
// Response for task history
|
||||
message TaskHistoryArray {
|
||||
repeated TaskItem tasks = 1;
|
||||
int32 total_count = 2;
|
||||
bool has_more = 2;
|
||||
}
|
||||
|
||||
// Task item details for history list
|
||||
@@ -114,6 +116,16 @@ message AskResponseRequest {
|
||||
repeated string files = 5;
|
||||
}
|
||||
|
||||
// Request for editing a past user message and regenerating the conversation after it
|
||||
message EditMessageAndRegenerateRequest {
|
||||
Metadata metadata = 1;
|
||||
int64 message_ts = 2;
|
||||
string text = 3;
|
||||
repeated string images = 4;
|
||||
repeated string files = 5;
|
||||
bool restore_workspace = 6;
|
||||
}
|
||||
|
||||
// Request for executing a quick win task
|
||||
message ExecuteQuickWinRequest {
|
||||
Metadata metadata = 1;
|
||||
@@ -125,10 +137,3 @@ message ExecuteQuickWinRequest {
|
||||
message DeleteAllTaskHistoryCount {
|
||||
int32 tasks_deleted = 1;
|
||||
}
|
||||
|
||||
// Request for explaining changes with AI
|
||||
message ExplainChangesRequest {
|
||||
Metadata metadata = 1;
|
||||
// Timestamp of the completion message to explain changes for
|
||||
int64 message_ts = 2;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,6 @@ enum ClineSay {
|
||||
INFO = 26;
|
||||
TASK_PROGRESS = 27;
|
||||
ERROR_RETRY = 28;
|
||||
GENERATE_EXPLANATION = 29;
|
||||
HOOK_STATUS = 30;
|
||||
HOOK_OUTPUT_STREAM = 31;
|
||||
COMMAND_PERMISSION_DENIED = 32;
|
||||
@@ -226,6 +225,12 @@ message ClineMessage {
|
||||
ClineAskNewTask ask_new_task = 21;
|
||||
ClineApiReqInfo api_req_info = 22;
|
||||
ClineModelInfo model_info = 23;
|
||||
|
||||
// Convergent-replica fields (see webview-message-state-design.md):
|
||||
// seq = monotonic freshness (higher seq wins for the same ts/identity)
|
||||
// epoch = conversation/replica fence (older epoch is dropped by the webview)
|
||||
int64 seq = 24;
|
||||
int64 epoch = 25;
|
||||
}
|
||||
|
||||
message ShowWebviewEvent {
|
||||
|
||||
@@ -34,9 +34,26 @@ const GRPC_JS_OUT_DIR = path.resolve("src/generated/grpc-js")
|
||||
const NICE_JS_OUT_DIR = path.resolve("src/generated/nice-grpc")
|
||||
const DESCRIPTOR_OUT_DIR = path.resolve("dist-standalone/proto")
|
||||
|
||||
const TS_PROTO_PLUGIN = isWindows
|
||||
? path.resolve("node_modules/.bin/protoc-gen-ts_proto.cmd") // Use the .bin directory path for Windows
|
||||
: require.resolve("ts-proto/protoc-gen-ts_proto")
|
||||
// protoc invokes the ts-proto plugin as a child process, so it needs a path it can
|
||||
// directly execute. On POSIX the package's JS bin (with its shebang) works. On
|
||||
// Windows protoc cannot exec a bare .js or bun's `.bunx` shim ("%1 is not a valid
|
||||
// Win32 application"), and the package manager's `.cmd` shim location/name varies
|
||||
// (npm vs bun's hoisted store). To be package-manager-agnostic, generate a tiny
|
||||
// .cmd wrapper that runs the resolved plugin JS via `node`.
|
||||
function resolveTsProtoPlugin() {
|
||||
const pluginJs = require.resolve("ts-proto/protoc-gen-ts_proto")
|
||||
if (!isWindows) {
|
||||
return pluginJs
|
||||
}
|
||||
const wrapperDir = path.resolve("dist-standalone")
|
||||
fsSync.mkdirSync(wrapperDir, { recursive: true })
|
||||
const wrapperPath = path.join(wrapperDir, "protoc-gen-ts_proto.cmd")
|
||||
// %* forwards protoc's plugin args/stdio to the JS entry run under node.
|
||||
fsSync.writeFileSync(wrapperPath, `@echo off\r\nnode "${pluginJs}" %*\r\n`)
|
||||
return wrapperPath
|
||||
}
|
||||
|
||||
const TS_PROTO_PLUGIN = resolveTsProtoPlugin()
|
||||
|
||||
const TS_PROTO_OPTIONS = [
|
||||
"env=both",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
const { execSync } = require("child_process")
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const esbuild = require("esbuild")
|
||||
|
||||
const watch = process.argv.includes("--watch")
|
||||
@@ -53,7 +55,43 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
execSync("tsc -p ./tsconfig.test.json --outDir out", { encoding: "utf-8" })
|
||||
// tsc does not delete output for source/tests that were removed or are no longer
|
||||
// part of tsconfig.test.json. The VS Code test runner globs out/src/**/*.test.js,
|
||||
// so stale compiled tests can still run unless we clear the test build output first.
|
||||
fs.rmSync(path.join(__dirname, "..", "out", "src"), { recursive: true, force: true })
|
||||
fs.rmSync(path.join(__dirname, "..", "out", "packages"), { recursive: true, force: true })
|
||||
|
||||
// Single source of truth for the bun-vs-integration test split: any *.test.ts that
|
||||
// imports from "bun:test" is owned by the bun runner (scripts/run-bun-unit-tests.ts)
|
||||
// and must NOT be compiled into the Node-based @vscode/test-cli `out/` tree (Node
|
||||
// cannot load the `bun:test` builtin, and these files use bun-only APIs like
|
||||
// `mock.module` / 3-arg `it`). Generate a tsconfig that excludes them so the
|
||||
// integration compile only ever sees mocha-owned tests.
|
||||
const projectRoot = path.join(__dirname, "..")
|
||||
const bunTestImport = /from\s+["']bun:test["']/
|
||||
function collectBunTestFiles(dir, acc) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === "node_modules") continue
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
collectBunTestFiles(full, acc)
|
||||
} else if (entry.isFile() && entry.name.endsWith(".test.ts")) {
|
||||
if (bunTestImport.test(fs.readFileSync(full, "utf-8"))) {
|
||||
acc.push(path.relative(projectRoot, full).split(path.sep).join("/"))
|
||||
}
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}
|
||||
const bunOwnedTests = collectBunTestFiles(path.join(projectRoot, "src"), [])
|
||||
// tsconfig.test.json is JSONC (contains comments); parse with json5 (a project dep).
|
||||
const JSON5 = require("json5")
|
||||
const baseTestConfig = JSON5.parse(fs.readFileSync(path.join(projectRoot, "tsconfig.test.json"), "utf-8"))
|
||||
baseTestConfig.exclude = [...(baseTestConfig.exclude ?? []), ...bunOwnedTests]
|
||||
const generatedConfigPath = path.join(projectRoot, "tsconfig.test.generated.json")
|
||||
fs.writeFileSync(generatedConfigPath, JSON.stringify(baseTestConfig, null, "\t"))
|
||||
|
||||
execSync(`tsc -p ${JSON.stringify(generatedConfigPath)} --outDir out`, { encoding: "utf-8" })
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e)
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// Dead-source finder: uses esbuild's own bundle reachability (the same analysis
|
||||
// that drives tree-shaking + minification mangling) to compute which src/ files
|
||||
// are reachable from BOTH shipped entry points:
|
||||
// - src/extension.ts (VS Code extension host)
|
||||
// - src/standalone/cline-core.ts (standalone host used by JetBrains + CLI)
|
||||
//
|
||||
// A src/*.ts file that is NOT in the union of metafile inputs for those two
|
||||
// builds is unreachable from any shipped entry => dead (modulo dynamic import()
|
||||
// of computed specifiers, which esbuild surfaces separately).
|
||||
//
|
||||
// Run: node scripts/find-dead-src.mjs
|
||||
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import * as esbuild from "esbuild"
|
||||
import { glob } from "glob"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const root = path.resolve(__dirname, "..")
|
||||
|
||||
const aliases = {
|
||||
"@": path.join(root, "src"),
|
||||
"@core": path.join(root, "src/core"),
|
||||
"@integrations": path.join(root, "src/integrations"),
|
||||
"@services": path.join(root, "src/services"),
|
||||
"@shared": path.join(root, "src/shared"),
|
||||
"@utils": path.join(root, "src/utils"),
|
||||
"@packages": path.join(root, "src/packages"),
|
||||
}
|
||||
|
||||
const aliasResolverPlugin = {
|
||||
name: "alias-resolver",
|
||||
setup(build) {
|
||||
for (const [alias, aliasPath] of Object.entries(aliases)) {
|
||||
const aliasRegex = new RegExp(`^${alias}($|/.*)`)
|
||||
build.onResolve({ filter: aliasRegex }, (args) => {
|
||||
const importPath = args.path.replace(alias, aliasPath)
|
||||
const exts = [".ts", ".tsx", ".js", ".jsx"]
|
||||
if (fs.existsSync(importPath)) {
|
||||
const stats = fs.statSync(importPath)
|
||||
if (stats.isDirectory()) {
|
||||
for (const ext of exts) {
|
||||
const idx = path.join(importPath, `index${ext}`)
|
||||
if (fs.existsSync(idx)) return { path: idx }
|
||||
}
|
||||
} else {
|
||||
return { path: importPath }
|
||||
}
|
||||
}
|
||||
for (const ext of exts) {
|
||||
if (fs.existsSync(`${importPath}${ext}`)) return { path: `${importPath}${ext}` }
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const common = {
|
||||
bundle: true,
|
||||
minify: false,
|
||||
sourcemap: false,
|
||||
logLevel: "silent",
|
||||
format: "cjs",
|
||||
platform: "node",
|
||||
metafile: true,
|
||||
write: false,
|
||||
absWorkingDir: root,
|
||||
tsconfig: path.join(root, "tsconfig.json"),
|
||||
packages: "external",
|
||||
plugins: [aliasResolverPlugin],
|
||||
define: { "process.env.IS_DEV": "false", "process.env.IS_TEST": "false" },
|
||||
banner: { js: "const _importMetaUrl=require('url').pathToFileURL(__filename)" },
|
||||
}
|
||||
|
||||
async function inputsFor(entry, external) {
|
||||
const r = await esbuild.build({ ...common, entryPoints: [entry], external })
|
||||
return new Set(Object.keys(r.metafile.inputs).filter((f) => f.startsWith("src/") && /\.tsx?$/.test(f)))
|
||||
}
|
||||
|
||||
const ext = await inputsFor("src/extension.ts", ["vscode"])
|
||||
const standalone = await inputsFor("src/standalone/cline-core.ts", [
|
||||
"vscode",
|
||||
"@grpc/reflection",
|
||||
"grpc-health-check",
|
||||
"better-sqlite3",
|
||||
])
|
||||
const live = new Set([...ext, ...standalone])
|
||||
|
||||
// Third consumer: the webview (webview-ui/) is a separate Vite/React build that
|
||||
// imports extension code ONLY from src/shared (via "@shared/*" alias or relative
|
||||
// "../src/shared/*" paths). Any src/shared file referenced from webview-ui/src is
|
||||
// therefore live even if the extension-host/standalone bundles don't reach it.
|
||||
// Conservatively mark every src/shared file mentioned by the webview as live.
|
||||
const webviewFiles = await glob("webview-ui/src/**/*.{ts,tsx}", { cwd: root })
|
||||
const sharedMentionedByWebview = new Set()
|
||||
for (const wf of webviewFiles) {
|
||||
const text = fs.readFileSync(path.join(root, wf), "utf8")
|
||||
// Match @shared/X or .../src/shared/X import specifiers and map to src/shared/X
|
||||
const re = /(?:@shared\/|src\/shared\/)([A-Za-z0-9_./-]+)/g
|
||||
let m
|
||||
while ((m = re.exec(text))) {
|
||||
const rel = m[1].replace(/\.(ts|tsx|js|jsx)$/, "")
|
||||
for (const cand of [`src/shared/${rel}.ts`, `src/shared/${rel}.tsx`, `src/shared/${rel}/index.ts`]) {
|
||||
if (fs.existsSync(path.join(root, cand))) sharedMentionedByWebview.add(cand)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const f of sharedMentionedByWebview) live.add(f)
|
||||
console.log(`src/shared files referenced by webview: ${sharedMentionedByWebview.size}`)
|
||||
|
||||
// All non-test, non-.d.ts source files on disk.
|
||||
const allSrc = (await glob("src/**/*.{ts,tsx}", { cwd: root }))
|
||||
.filter((f) => !/\.test\.tsx?$/.test(f))
|
||||
.filter((f) => !f.endsWith(".d.ts"))
|
||||
.filter((f) => !f.includes("/__tests__/"))
|
||||
.filter((f) => !f.startsWith("src/test/"))
|
||||
.filter((f) => !f.startsWith("src/generated/")) // generated host glue
|
||||
.filter((f) => !f.startsWith("src/dev/")) // dev-only tooling
|
||||
|
||||
const dead = allSrc.filter((f) => !live.has(f)).sort()
|
||||
|
||||
console.log(`extension inputs: ${ext.size}`)
|
||||
console.log(`standalone inputs: ${standalone.size}`)
|
||||
console.log(`union live src files: ${live.size}`)
|
||||
console.log(`candidate dead files: ${dead.length}`)
|
||||
fs.writeFileSync("/tmp/dead-src.json", JSON.stringify(dead, null, "\t"))
|
||||
console.log("--- dead candidates written to /tmp/dead-src.json ---")
|
||||
@@ -1,212 +0,0 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const require = createRequire(import.meta.url);
|
||||
const execFileAsync = promisify(execFile);
|
||||
const repoRoot = path.resolve(__dirname, "../../..");
|
||||
const sdkCatalogPath = path.join(
|
||||
repoRoot,
|
||||
"sdk/packages/llms/src/catalog/catalog.generated.ts",
|
||||
);
|
||||
const outputPath = path.join(
|
||||
__dirname,
|
||||
"../src/shared/models/models-dev-catalog.generated.ts",
|
||||
);
|
||||
|
||||
const { GENERATED_PROVIDER_MODELS } = await import(
|
||||
pathToFileURL(sdkCatalogPath).href
|
||||
);
|
||||
|
||||
const providerLabels = Object.fromEntries([
|
||||
["anthropic", "Anthropic"],
|
||||
["bedrock", "Amazon Bedrock"],
|
||||
["vertex", "GCP Vertex AI"],
|
||||
["gemini", "Google Gemini"],
|
||||
["openai-native", "OpenAI"],
|
||||
["openai-codex", "ChatGPT Subscription"],
|
||||
["deepseek", "DeepSeek"],
|
||||
["xai", "xAI"],
|
||||
["together", "Together"],
|
||||
["sapaicore", "SAP AI Core"],
|
||||
["fireworks", "Fireworks AI"],
|
||||
["groq", "Groq"],
|
||||
["cerebras", "Cerebras"],
|
||||
["sambanova", "SambaNova"],
|
||||
["nebius", "Nebius AI Studio"],
|
||||
["huggingface", "Hugging Face"],
|
||||
["openrouter", "OpenRouter"],
|
||||
["vercel-ai-gateway", "Vercel AI Gateway"],
|
||||
["aihubmix", "AIhubmix"],
|
||||
["baseten", "Baseten"],
|
||||
["zai", "Z AI"],
|
||||
["lmstudio", "LM Studio"],
|
||||
["requesty", "Requesty"],
|
||||
["moonshot", "Moonshot"],
|
||||
["minimax", "MiniMax"],
|
||||
["wandb", "W&B Inference by CoreWeave"],
|
||||
["mistral", "Mistral"],
|
||||
["doubao", "Bytedance Doubao"],
|
||||
["qwen", "Alibaba Qwen"],
|
||||
["huawei-cloud-maas", "Huawei Cloud MaaS"],
|
||||
["hicap", "Hicap"],
|
||||
["nousResearch", "NousResearch"],
|
||||
["openai", "OpenAI Compatible"],
|
||||
["ollama", "Ollama"],
|
||||
["litellm", "LiteLLM"],
|
||||
["claude-code", "Claude Code"],
|
||||
["qwen-code", "Qwen Code"],
|
||||
["dify", "Dify.ai"],
|
||||
["oca", "Oracle Code Assist"],
|
||||
["vscode-lm", "GitHub Copilot"],
|
||||
["cline", "Cline"],
|
||||
["cline-pass", "ClinePass"],
|
||||
["asksage", "AskSage"],
|
||||
]);
|
||||
|
||||
const providerOrder = [
|
||||
"cline",
|
||||
"cline-pass",
|
||||
"openai-codex",
|
||||
"gemini",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"bedrock",
|
||||
"vscode-lm",
|
||||
"deepseek",
|
||||
"openai-native",
|
||||
"openrouter",
|
||||
"ollama",
|
||||
"vertex",
|
||||
"litellm",
|
||||
"claude-code",
|
||||
"sapaicore",
|
||||
"mistral",
|
||||
"zai",
|
||||
"groq",
|
||||
"cerebras",
|
||||
"vercel-ai-gateway",
|
||||
"baseten",
|
||||
"requesty",
|
||||
"fireworks",
|
||||
"together",
|
||||
"qwen",
|
||||
"qwen-code",
|
||||
"doubao",
|
||||
"lmstudio",
|
||||
"moonshot",
|
||||
"huggingface",
|
||||
"nebius",
|
||||
"asksage",
|
||||
"xai",
|
||||
"sambanova",
|
||||
"huawei-cloud-maas",
|
||||
"dify",
|
||||
"oca",
|
||||
"minimax",
|
||||
"hicap",
|
||||
"aihubmix",
|
||||
"nousResearch",
|
||||
"wandb",
|
||||
];
|
||||
|
||||
function toLegacyModelInfo(model) {
|
||||
const capabilities = new Set(model.capabilities ?? []);
|
||||
const output = {
|
||||
name: model.name,
|
||||
maxTokens: model.maxTokens,
|
||||
contextWindow: model.contextWindow ?? model.maxInputTokens,
|
||||
supportsImages: capabilities.has("images"),
|
||||
supportsPromptCache: capabilities.has("prompt-cache"),
|
||||
supportsReasoning: capabilities.has("reasoning"),
|
||||
inputPrice: model.pricing?.input ?? 0,
|
||||
outputPrice: model.pricing?.output ?? 0,
|
||||
cacheWritesPrice: model.pricing?.cacheWrite ?? 0,
|
||||
cacheReadsPrice: model.pricing?.cacheRead ?? 0,
|
||||
supportsTools: capabilities.has("tools"),
|
||||
};
|
||||
|
||||
for (const key of Object.keys(output)) {
|
||||
if (output[key] === undefined) {
|
||||
delete output[key];
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
const providerModels = Object.fromEntries(
|
||||
Object.entries(GENERATED_PROVIDER_MODELS.providers).map(
|
||||
([providerId, models]) => [
|
||||
providerId,
|
||||
Object.fromEntries(
|
||||
Object.entries(models).map(([modelId, model]) => [
|
||||
modelId,
|
||||
toLegacyModelInfo(model),
|
||||
]),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
const providerOptions = providerOrder
|
||||
.filter((value) => providerLabels[value])
|
||||
.map((value) => ({ value, label: providerLabels[value] }));
|
||||
|
||||
const file = `/**
|
||||
* Auto-generated from @cline/llms models.dev catalog.
|
||||
*
|
||||
* Source: sdk/packages/llms/src/catalog/catalog.generated.ts
|
||||
* Do not edit by hand; run apps/vscode/scripts/generate-models-dev-catalog.mjs after updating the SDK model catalog.
|
||||
*/
|
||||
|
||||
import type { ApiProvider, ModelInfo, OpenAiCompatibleModelInfo } from "../api"
|
||||
|
||||
export const modelsDevProviderModels = ${JSON.stringify(providerModels, null, "\t")} as const satisfies Record<string, Record<string, ModelInfo | OpenAiCompatibleModelInfo>>
|
||||
|
||||
export const modelsDevProviderOptions = ${JSON.stringify(providerOptions, null, "\t")} as const satisfies ReadonlyArray<{ value: ApiProvider; label: string }>
|
||||
|
||||
export function getModelsDevProviderModels(provider: ApiProvider | string): Record<string, ModelInfo> {
|
||||
\treturn (modelsDevProviderModels[provider as keyof typeof modelsDevProviderModels] ?? {}) as Record<string, ModelInfo>
|
||||
}
|
||||
|
||||
export const modelsDevAnthropicModels = getModelsDevProviderModels("anthropic")
|
||||
export const modelsDevBedrockModels = getModelsDevProviderModels("bedrock")
|
||||
export const modelsDevCerebrasModels = getModelsDevProviderModels("cerebras")
|
||||
export const modelsDevDeepSeekModels = getModelsDevProviderModels("deepseek")
|
||||
export const modelsDevDoubaoModels = getModelsDevProviderModels("doubao")
|
||||
export const modelsDevFireworksModels = getModelsDevProviderModels("fireworks")
|
||||
export const modelsDevGeminiModels = getModelsDevProviderModels("gemini")
|
||||
export const modelsDevGroqModels = getModelsDevProviderModels("groq")
|
||||
export const modelsDevHuggingFaceModels = getModelsDevProviderModels("huggingface")
|
||||
export const modelsDevMinimaxModels = getModelsDevProviderModels("minimax")
|
||||
export const modelsDevMistralModels = getModelsDevProviderModels("mistral")
|
||||
export const modelsDevMoonshotModels = getModelsDevProviderModels("moonshot")
|
||||
export const modelsDevNebiusModels = getModelsDevProviderModels("nebius")
|
||||
export const modelsDevNousResearchModels = getModelsDevProviderModels("nousResearch")
|
||||
export const modelsDevOpenAiCodexModels = getModelsDevProviderModels("openai-codex")
|
||||
export const modelsDevOpenAiNativeModels = getModelsDevProviderModels("openai-native")
|
||||
export const modelsDevSambanovaModels = getModelsDevProviderModels("sambanova")
|
||||
export const modelsDevSapAiCoreModels = getModelsDevProviderModels("sapaicore")
|
||||
export const modelsDevVertexModels = getModelsDevProviderModels("vertex")
|
||||
export const modelsDevWandbModels = getModelsDevProviderModels("wandb")
|
||||
export const modelsDevXaiModels = getModelsDevProviderModels("xai")
|
||||
`;
|
||||
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await fs.writeFile(outputPath, file);
|
||||
await execFileAsync(
|
||||
process.execPath,
|
||||
[
|
||||
require.resolve("@biomejs/biome/bin/biome"),
|
||||
"format",
|
||||
"--write",
|
||||
outputPath,
|
||||
],
|
||||
{ cwd: path.resolve(__dirname, "..") },
|
||||
);
|
||||
@@ -453,7 +453,7 @@ async function main() {
|
||||
await fs.writeFile(STATE_PROTO_PATH, protoContent)
|
||||
console.log(`Updated ${STATE_PROTO_PATH}`)
|
||||
|
||||
console.log("\nGeneration complete! Run 'npm run protos' to regenerate TypeScript from protos.")
|
||||
console.log("\nGeneration complete! Run 'bun run protos' to regenerate TypeScript from protos.")
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Interactive Playwright launcher for the Cline VS Code extension.
|
||||
@@ -15,10 +15,10 @@
|
||||
*
|
||||
* Usage:
|
||||
* 1. (Optional) Build and install the e2e extension:
|
||||
* npm run test:e2e:build
|
||||
* bun run test:e2e:build
|
||||
*
|
||||
* 2. From the repo root, start the interactive session:
|
||||
* npm run test:e2e:ui
|
||||
* bun run test:e2e:ui
|
||||
*
|
||||
* 3. VS Code will launch with the Cline extension loaded and gRPC recording enabled.
|
||||
*
|
||||
|
||||
@@ -47,8 +47,13 @@ async function installNodeDependencies() {
|
||||
|
||||
await cpr(RUNTIME_DEPS_DIR, BUILD_DIR)
|
||||
|
||||
console.log("Running npm install in distribution directory...")
|
||||
execSync("npm install", { stdio: "inherit", cwd: BUILD_DIR })
|
||||
// This is an ISOLATED runtime install inside the standalone distribution
|
||||
// directory (dist-standalone), driven by the "cline-core" runtime-files
|
||||
// manifest — it is NOT part of the monorepo workspace install. TARGET_NODE_VERSION
|
||||
// and the prebuild-install calls below target the Node ABI of the bundled
|
||||
// runtime (matching the JetBrains-packaged Node), not the build tooling.
|
||||
console.log("Running bun install in distribution directory...")
|
||||
execSync("bun install", { stdio: "inherit", cwd: BUILD_DIR })
|
||||
|
||||
// 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.
|
||||
@@ -92,7 +97,10 @@ async function packageAllBinaryDeps() {
|
||||
const dest = path.join(binaryDir, module)
|
||||
await cpr(src, dest)
|
||||
|
||||
// Download the binary libs
|
||||
// Download the binary libs.
|
||||
// `--target=${TARGET_NODE_VERSION}` selects the Node ABI of the bundled
|
||||
// standalone runtime (NOT the bun/build tooling) so the prebuilt native
|
||||
// `.node` binaries load in the Node that runs cline-core.
|
||||
const v = IS_VERBOSE ? "--verbose" : ""
|
||||
const cmd = `npx prebuild-install --platform=${platform} --arch=${arch} --target=${TARGET_NODE_VERSION} ${v}`
|
||||
log_verbose(`${module}: ${cmd}`)
|
||||
|
||||
@@ -37,13 +37,16 @@ process.on("SIGINT", cleanupOnSignal(130))
|
||||
process.on("SIGTERM", cleanupOnSignal(143))
|
||||
|
||||
try {
|
||||
const vsceArgs = ["publish", "--allow-package-secrets", "sendgrid"]
|
||||
// --no-dependencies: the extension is fully esbuild-bundled into dist/extension.js,
|
||||
// so vsce must not walk node_modules (the @cline/* workspace symlinks point out of
|
||||
// the package and would drag the whole monorepo into the VSIX).
|
||||
const vsceArgs = ["publish", "--no-dependencies", "--allow-package-secrets", "sendgrid"]
|
||||
if (isPrerelease) {
|
||||
vsceArgs.push("--pre-release")
|
||||
}
|
||||
execFileSync("vsce", vsceArgs, { stdio: "inherit" })
|
||||
|
||||
const ovsxArgs = ["ovsx", "publish"]
|
||||
const ovsxArgs = ["ovsx", "publish", "--no-dependencies"]
|
||||
if (isPrerelease) {
|
||||
ovsxArgs.push("--pre-release")
|
||||
}
|
||||
|
||||
@@ -35,9 +35,9 @@
|
||||
* least as often as the scheduled release nightly runs.
|
||||
*
|
||||
* Usage:
|
||||
* npm run publish:marketplace:nightly # release channel
|
||||
* npm run publish:marketplace:nightly -- --pre-release # pre-release channel
|
||||
* npm run publish:marketplace:nightly -- --dry-run # package only
|
||||
* bun run publish:marketplace:nightly # release channel
|
||||
* bun run publish:marketplace:nightly -- --pre-release # pre-release channel
|
||||
* bun run publish:marketplace:nightly -- --dry-run # package only
|
||||
*
|
||||
* Environment variables:
|
||||
* VSCE_PAT - Personal Access Token for VS Code Marketplace
|
||||
@@ -388,6 +388,9 @@ class NightlyPublisher {
|
||||
const args = [
|
||||
"package",
|
||||
...(isPreRelease ? ["--pre-release"] : []),
|
||||
// The extension is fully esbuild-bundled, so vsce must not walk node_modules
|
||||
// (the @cline/* workspace symlinks point outside the package).
|
||||
"--no-dependencies",
|
||||
"--no-update-package-json",
|
||||
"--no-git-tag-version",
|
||||
"--allow-package-secrets",
|
||||
@@ -579,7 +582,7 @@ if (showHelp) {
|
||||
Nightly publish script for VS Code extension
|
||||
|
||||
Usage:
|
||||
npm run publish:marketplace:nightly [options]
|
||||
bun run publish:marketplace:nightly [options]
|
||||
|
||||
Options:
|
||||
--pre-release Publish to the pre-release channel of cline-nightly.
|
||||
@@ -593,10 +596,10 @@ Environment variables:
|
||||
OVSX_PAT Personal Access Token for OpenVSX Registry
|
||||
|
||||
Examples:
|
||||
npm run publish:marketplace:nightly # Release channel publish
|
||||
npm run publish:marketplace:nightly -- --pre-release # Pre-release channel publish
|
||||
npm run publish:marketplace:nightly -- --dry-run # Package only
|
||||
VSCE_PAT="token" npm run publish:marketplace:nightly # Publish to VS Code only
|
||||
bun run publish:marketplace:nightly # Release channel publish
|
||||
bun run publish:marketplace:nightly -- --pre-release # Pre-release channel publish
|
||||
bun run publish:marketplace:nightly -- --dry-run # Package only
|
||||
VSCE_PAT="token" bun run publish:marketplace:nightly # Publish to VS Code only
|
||||
`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { spawn } from "node:child_process"
|
||||
import path from "node:path"
|
||||
/**
|
||||
* Runner for the SDK-adapter + model-catalog `bun test` suites (the same set
|
||||
* `vitest.config.ts` covers; `test:vitest` runs them under vitest).
|
||||
*
|
||||
* Why a script instead of a bare `bun test <globs>`:
|
||||
*
|
||||
* 1. Curated include set. These suites are an explicit list (see
|
||||
* INCLUDE_PATTERNS, kept in sync with `vitest.config.ts` `test.include`),
|
||||
* not the whole tree, so the node-side unit and @vscode/test-cli suites are
|
||||
* not pulled in. `bun test`'s positional args don't expand `**` the way we
|
||||
* need, so we resolve the globs ourselves with bun's `Glob`.
|
||||
*
|
||||
* 2. One process per file. `bun test` runs all files in a single process by
|
||||
* default, so `mock.module(...)` registrations leak between files — suites
|
||||
* mocking the same specifier with different shapes (e.g.
|
||||
* `@/core/storage/StateManager`, `@cline/core`) clobber each other.
|
||||
* `--parallel` runs each file in its own worker process, giving each a fresh
|
||||
* module registry.
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/run-bun-tests.ts # run the curated set, isolated
|
||||
* bun scripts/run-bun-tests.ts --list # print the resolved file list only
|
||||
*/
|
||||
import { Glob } from "bun"
|
||||
|
||||
// Mirror of vitest.config.ts `test.include`. Keep these in sync.
|
||||
const INCLUDE_PATTERNS = [
|
||||
"src/sdk/**/*.test.ts",
|
||||
"src/shared/vsCodeSelectorUtils.test.ts",
|
||||
"src/core/storage/remote-config/**/*.test.ts",
|
||||
"src/shared/model-catalog/provider-helpers.test.ts",
|
||||
"src/core/controller/models/__tests__/providerCatalogHandlers.test.ts",
|
||||
"src/core/controller/models/__tests__/providerSwitchNormalization.test.ts",
|
||||
"src/core/controller/models/__tests__/resolveModelInfo.test.ts",
|
||||
"src/core/controller/models/__tests__/providerCatalogSmoke.test.ts",
|
||||
"src/core/controller/models/__tests__/refreshClineRecommendedModels.test.ts",
|
||||
]
|
||||
|
||||
const projectRoot = path.resolve(import.meta.dir, "..")
|
||||
|
||||
async function resolveFiles(): Promise<string[]> {
|
||||
const seen = new Set<string>()
|
||||
for (const pattern of INCLUDE_PATTERNS) {
|
||||
const glob = new Glob(pattern)
|
||||
for await (const match of glob.scan({ cwd: projectRoot, onlyFiles: true })) {
|
||||
seen.add(match)
|
||||
}
|
||||
}
|
||||
return [...seen].sort()
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const files = await resolveFiles()
|
||||
if (files.length === 0) {
|
||||
console.error("run-bun-tests: no test files matched the include patterns")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const passthrough = process.argv.slice(2)
|
||||
if (passthrough.includes("--list")) {
|
||||
console.log(files.join("\n"))
|
||||
return
|
||||
}
|
||||
|
||||
const args = ["test", "--parallel", ...passthrough.filter((arg) => arg !== "--list"), ...files]
|
||||
const child = spawn("bun", args, { cwd: projectRoot, stdio: "inherit" })
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal)
|
||||
return
|
||||
}
|
||||
process.exit(code ?? 1)
|
||||
})
|
||||
}
|
||||
|
||||
void main()
|
||||
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import path from "node:path"
|
||||
/**
|
||||
* Runner for the node-side `bun test` unit suites.
|
||||
*
|
||||
* A test file belongs to this runner iff it imports from "bun:test" (files that
|
||||
* need the real VS Code extension host import from "mocha" and run under
|
||||
* @vscode/test-cli instead). We glob all `*.test.ts` and keep only the
|
||||
* bun:test ones; the SDK/model-catalog suites listed in IGNORED run through
|
||||
* `run-bun-tests.ts`, so they're skipped here to avoid double-running.
|
||||
*
|
||||
* Why one process per file: `bun test --parallel <allFiles>` reuses a pool of
|
||||
* worker processes, and `mock.module(...)` registrations accumulate across files
|
||||
* sharing a worker. Suites that mock the same specifier with different shapes
|
||||
* (e.g. `@core/storage/disk`, `@cline/core`, `fs/promises`, `os`) then clobber
|
||||
* each other and fail only at scale. Spawning one `bun test` process per file
|
||||
* (bounded by a small concurrency pool, via `Bun.spawn`) gives each file a fresh
|
||||
* module registry.
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/run-bun-unit-tests.ts # run the suite, isolated
|
||||
* bun scripts/run-bun-unit-tests.ts --list # print the resolved file list
|
||||
* bun scripts/run-bun-unit-tests.ts --all # include ELECTRON_HOST_ONLY
|
||||
* bun scripts/run-bun-unit-tests.ts -c 6 # concurrency (default 4)
|
||||
*/
|
||||
import { Glob } from "bun"
|
||||
|
||||
const projectRoot = path.resolve(import.meta.dir, "..")
|
||||
|
||||
// A file runs under `bun test` iff it imports "bun:test"; mocha-owned files are
|
||||
// skipped by the import filter in resolveFiles().
|
||||
const INCLUDE_PATTERNS = ["src/**/*.test.ts"]
|
||||
const BUN_TEST_IMPORT = /from\s+["']bun:test["']/
|
||||
|
||||
// SDK + model-catalog suites run through `run-bun-tests.ts`; skip them here so
|
||||
// they aren't run twice.
|
||||
const IGNORED = new Set<string>([
|
||||
"src/core/controller/models/__tests__/providerCatalogHandlers.test.ts",
|
||||
"src/core/controller/models/__tests__/providerCatalogSmoke.test.ts",
|
||||
"src/core/controller/models/__tests__/providerSwitchNormalization.test.ts",
|
||||
"src/core/controller/models/__tests__/resolveModelInfo.test.ts",
|
||||
"src/core/controller/models/__tests__/refreshClineRecommendedModels.test.ts",
|
||||
])
|
||||
|
||||
// Files that require the real VSCode Electron host (@vscode/test-cli). Excluded
|
||||
// by default; they continue to run under @vscode/test-cli.
|
||||
const ELECTRON_HOST_ONLY = new Set<string>([])
|
||||
|
||||
async function resolveFiles(includeHostOnly: boolean): Promise<string[]> {
|
||||
const seen = new Set<string>()
|
||||
for (const pattern of INCLUDE_PATTERNS) {
|
||||
const glob = new Glob(pattern)
|
||||
for await (const match of glob.scan({ cwd: projectRoot, onlyFiles: true })) {
|
||||
const normalized = match.split(path.sep).join("/")
|
||||
if (IGNORED.has(normalized)) {
|
||||
continue
|
||||
}
|
||||
if (!includeHostOnly && ELECTRON_HOST_ONLY.has(normalized)) {
|
||||
continue
|
||||
}
|
||||
// Only bun-runner-owned files (those importing "bun:test"). Files still on
|
||||
// the @vscode/test-cli Electron host import from "mocha" and are skipped.
|
||||
const source = await Bun.file(path.join(projectRoot, normalized)).text()
|
||||
if (!BUN_TEST_IMPORT.test(source)) {
|
||||
continue
|
||||
}
|
||||
seen.add(normalized)
|
||||
}
|
||||
}
|
||||
return [...seen].sort()
|
||||
}
|
||||
|
||||
type FileResult = {
|
||||
file: string
|
||||
code: number
|
||||
pass: number
|
||||
fail: number
|
||||
output: string
|
||||
}
|
||||
|
||||
// `bun test` prints its summary as e.g. " 12 pass\n 0 fail".
|
||||
function parseCounts(output: string): { pass: number; fail: number } {
|
||||
let pass = 0
|
||||
let fail = 0
|
||||
for (const m of output.matchAll(/^\s*(\d+)\s+pass\b/gm)) {
|
||||
pass += Number(m[1])
|
||||
}
|
||||
for (const m of output.matchAll(/^\s*(\d+)\s+fail\b/gm)) {
|
||||
fail += Number(m[1])
|
||||
}
|
||||
return { pass, fail }
|
||||
}
|
||||
|
||||
const PER_FILE_TIMEOUT_MS = 120_000
|
||||
|
||||
async function runOne(file: string): Promise<FileResult> {
|
||||
const proc = Bun.spawn(["bun", "test", file], {
|
||||
cwd: projectRoot,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: { ...process.env, FORCE_COLOR: "0" },
|
||||
})
|
||||
// Guard against a single hung file stalling the whole pool: kill it after a
|
||||
// generous per-file budget and surface it as a failure.
|
||||
let timedOut = false
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true
|
||||
proc.kill()
|
||||
}, PER_FILE_TIMEOUT_MS)
|
||||
const [stdout, stderr, code] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
])
|
||||
clearTimeout(timer)
|
||||
const output = stdout + stderr + (timedOut ? `\n[runner] TIMEOUT after ${PER_FILE_TIMEOUT_MS}ms — killed\n` : "")
|
||||
const { pass, fail } = parseCounts(output)
|
||||
// A nonzero exit with no parsed counts (load/parse error, timeout) must count
|
||||
// as a failure so the gate cannot pass silently.
|
||||
const effectiveFail = timedOut && fail === 0 ? Math.max(fail, 1) : fail
|
||||
return { file, code, pass, fail: effectiveFail, output }
|
||||
}
|
||||
|
||||
async function runPool(files: string[], concurrency: number): Promise<FileResult[]> {
|
||||
const results: FileResult[] = []
|
||||
let next = 0
|
||||
const launch = async (): Promise<void> => {
|
||||
while (next < files.length) {
|
||||
const file = files[next++]
|
||||
const result = await runOne(file)
|
||||
results.push(result)
|
||||
const failed = result.fail > 0 || result.code !== 0
|
||||
const status = failed ? "FAIL" : "ok"
|
||||
const counts = `${result.pass} pass / ${result.fail} fail`
|
||||
process.stdout.write(`[${results.length}/${files.length}] ${status.padEnd(4)} ${counts.padEnd(20)} ${file}\n`)
|
||||
if (failed) {
|
||||
process.stdout.write(result.output.trimEnd() + "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
const workers: Promise<void>[] = []
|
||||
for (let i = 0; i < Math.min(concurrency, files.length); i++) {
|
||||
workers.push(launch())
|
||||
}
|
||||
await Promise.all(workers)
|
||||
return results
|
||||
}
|
||||
|
||||
function parseConcurrency(argv: string[]): number {
|
||||
const flagIdx = argv.findIndex((a) => a === "-c" || a === "--concurrency")
|
||||
if (flagIdx !== -1 && argv[flagIdx + 1]) {
|
||||
const n = Number(argv[flagIdx + 1])
|
||||
if (Number.isFinite(n) && n > 0) {
|
||||
return Math.floor(n)
|
||||
}
|
||||
}
|
||||
return 4
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const passthrough = process.argv.slice(2)
|
||||
const includeHostOnly = passthrough.includes("--all")
|
||||
const files = await resolveFiles(includeHostOnly)
|
||||
if (files.length === 0) {
|
||||
console.error("run-bun-unit-tests: no test files matched")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (passthrough.includes("--list")) {
|
||||
console.log(files.join("\n"))
|
||||
return
|
||||
}
|
||||
|
||||
const concurrency = parseConcurrency(passthrough)
|
||||
const started = Date.now()
|
||||
console.log(`Running ${files.length} unit test files, isolated (concurrency ${concurrency})…\n`)
|
||||
|
||||
const results = await runPool(files, concurrency)
|
||||
|
||||
const totalPass = results.reduce((sum, r) => sum + r.pass, 0)
|
||||
const totalFail = results.reduce((sum, r) => sum + r.fail, 0)
|
||||
const failedFiles = results.filter((r) => r.fail > 0 || r.code !== 0).sort((a, b) => a.file.localeCompare(b.file))
|
||||
const elapsed = ((Date.now() - started) / 1000).toFixed(1)
|
||||
|
||||
console.log("\n──────────────────────────────────────────────")
|
||||
console.log(`Files: ${results.length} Pass: ${totalPass} Fail: ${totalFail} Time: ${elapsed}s`)
|
||||
if (failedFiles.length > 0) {
|
||||
console.log(`\nFailing files (${failedFiles.length}):`)
|
||||
for (const r of failedFiles) {
|
||||
console.log(` ${r.file} (${r.pass} pass / ${r.fail} fail, exit ${r.code})`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
console.log("All unit test files passed.")
|
||||
}
|
||||
|
||||
void main()
|
||||
@@ -18,11 +18,11 @@ fi
|
||||
|
||||
# Step 1: Build protos (everything depends on this)
|
||||
echo "Building protos..."
|
||||
npm run protos || { echo "Protos build failed"; exit 1; }
|
||||
bun run protos || { echo "Protos build failed"; exit 1; }
|
||||
|
||||
# Step 2: Build webview once
|
||||
echo "Building webview..."
|
||||
npm run build:webview || { echo "Webview build failed"; exit 1; }
|
||||
bun run build:webview || { echo "Webview build failed"; exit 1; }
|
||||
|
||||
# Step 3: Kill existing session if one is running
|
||||
tmux kill-session -t "$SESSION" 2>/dev/null
|
||||
@@ -44,9 +44,9 @@ tmux select-layout -t "$SESSION" even-horizontal
|
||||
# Ctrl+C kills the whole session
|
||||
tmux bind-key -T root C-c kill-session
|
||||
|
||||
tmux send-keys -t "$SESSION:0.0" "npm run watch:esbuild" Enter
|
||||
tmux send-keys -t "$SESSION:0.1" "npm run watch:tsc" Enter
|
||||
tmux send-keys -t "$SESSION:0.2" "npm run dev:webview" Enter
|
||||
tmux send-keys -t "$SESSION:0.0" "bun run watch:esbuild" Enter
|
||||
tmux send-keys -t "$SESSION:0.1" "bun run watch:tsc" Enter
|
||||
tmux send-keys -t "$SESSION:0.2" "bun run dev:webview" Enter
|
||||
tmux send-keys -t "$SESSION:0.3" "while [ ! -f '$WORKSPACE/dist/extension.js' ]; do sleep 0.5; done && echo 'Launching Extension Host...' && code --extensionDevelopmentPath='$WORKSPACE' --disable-workspace-trust --disable-extension saoudrizwan.claude-dev --disable-extension saoudrizwan.cline-nightly '$WORKSPACE' && echo 'Extension Host launched.'" Enter
|
||||
|
||||
# Attach to the session
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
#!/usr/bin/env bun
|
||||
import * as grpc from "@grpc/grpc-js"
|
||||
import { ReflectionService } from "@grpc/reflection"
|
||||
import * as health from "grpc-health-check"
|
||||
@@ -87,6 +87,12 @@ function createMockService<T extends grpc.UntypedServiceImplementation>(serviceN
|
||||
})
|
||||
return
|
||||
|
||||
case "openExternal":
|
||||
simulateOAuthBrowserCallback(call.request?.value || "")
|
||||
.then(() => callback(null, {}))
|
||||
.catch((error) => callback(error))
|
||||
return
|
||||
|
||||
case "getWebviewHtml":
|
||||
callback(null, {
|
||||
html: "<html><body>Fake Webview</body></html>",
|
||||
@@ -143,6 +149,41 @@ function createMockService<T extends grpc.UntypedServiceImplementation>(serviceN
|
||||
return new Proxy({} as T, handler)
|
||||
}
|
||||
|
||||
async function simulateOAuthBrowserCallback(urlString: string): Promise<void> {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(urlString)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isLoopbackHost(url.hostname) || url.pathname !== "/api/v1/auth/authorize") {
|
||||
return
|
||||
}
|
||||
|
||||
const callbackUrl = url.searchParams.get("callback_url") ?? url.searchParams.get("redirect_uri")
|
||||
if (!callbackUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
const callback = new URL(callbackUrl)
|
||||
if (!isLoopbackHost(callback.hostname) || callback.pathname !== "/auth") {
|
||||
return
|
||||
}
|
||||
|
||||
callback.searchParams.set("code", "test-personal-token")
|
||||
callback.searchParams.set("provider", "cline")
|
||||
|
||||
const response = await fetch(callback.toString())
|
||||
if (!response.ok) {
|
||||
throw new Error(`Mock OAuth callback failed: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
}
|
||||
|
||||
function isLoopbackHost(hostname: string): boolean {
|
||||
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
startTestHostBridgeServer().catch((err) => {
|
||||
console.error("Failed to start test host bridge server:", err)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Simple Cline gRPC Server
|
||||
@@ -7,13 +7,13 @@
|
||||
* without requiring the full installation, while automatically mocking all external services. Simply run:
|
||||
*
|
||||
* # One-time setup (generates protobuf files)
|
||||
* npm run compile-standalone
|
||||
* npm run test:sca-server
|
||||
* bun run compile-standalone
|
||||
* bun run test:sca-server
|
||||
*
|
||||
* The following components are started automatically:
|
||||
* 1. HostBridge test server
|
||||
* 2. ClineApiServerMock (mock implementation of the Cline API)
|
||||
* 3. AuthServiceMock (activated if E2E_TEST="true")
|
||||
* 3. SDK WorkOS device-auth flow, with WorkOS fetches mocked by testing-platform-workos-fetch-mock.cjs
|
||||
*
|
||||
* Environment Variables for Customization:
|
||||
* PROJECT_ROOT - Override project root directory (default: parent of scripts dir)
|
||||
@@ -22,7 +22,7 @@
|
||||
* PROTOBUS_PORT - gRPC server port (default: 26040)
|
||||
* HOSTBRIDGE_PORT - HostBridge server port (default: 26041)
|
||||
* WORKSPACE_DIR - Working directory (default: current directory)
|
||||
* E2E_TEST - Enable E2E test mode (default: true)
|
||||
* E2E_TEST - Enable legacy mock auth mode (default: false)
|
||||
* CLINE_ENVIRONMENT - Environment setting (default: local)
|
||||
*
|
||||
* Ideal for local development, testing, or lightweight E2E scenarios.
|
||||
@@ -38,7 +38,7 @@ import { ClineApiServerMock } from "../src/test/e2e/fixtures/server/index"
|
||||
const PROTOBUS_PORT = process.env.PROTOBUS_PORT || "26040"
|
||||
const HOSTBRIDGE_PORT = process.env.HOSTBRIDGE_PORT || "26041"
|
||||
const WORKSPACE_DIR = process.env.WORKSPACE_DIR || process.cwd()
|
||||
const E2E_TEST = process.env.E2E_TEST || "true"
|
||||
const E2E_TEST = process.env.E2E_TEST || "false"
|
||||
const CLINE_ENVIRONMENT = process.env.CLINE_ENVIRONMENT || "local"
|
||||
const USE_C8 = process.env.USE_C8 === "true"
|
||||
|
||||
@@ -66,7 +66,7 @@ async function main(): Promise<void> {
|
||||
console.error(" CLINE_DIST_DIR - Override distribution directory")
|
||||
console.error(" CLINE_CORE_FILE - Override core file name")
|
||||
console.error("")
|
||||
console.error("To build the standalone version, run: npm run compile-standalone")
|
||||
console.error("To build the standalone version, run: bun run compile-standalone")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
@@ -83,8 +83,8 @@ async function main(): Promise<void> {
|
||||
const clineTestWorkspace = mkdtempSync(path.join(os.tmpdir(), "cline-test-workspace-"))
|
||||
|
||||
console.log("Starting HostBridge test server...")
|
||||
const hostbridge: ChildProcess = spawn("npx", ["tsx", path.join(__dirname, "test-hostbridge-server.ts")], {
|
||||
stdio: "pipe",
|
||||
const hostbridge: ChildProcess = spawn("bun", [path.join(__dirname, "test-hostbridge-server.ts")], {
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
TEST_HOSTBRIDGE_WORKSPACE_DIR: clineTestWorkspace,
|
||||
@@ -115,13 +115,16 @@ async function main(): Promise<void> {
|
||||
|
||||
const covDir = path.join(projectRoot, `coverage/coverage-core-${PROTOBUS_PORT}`)
|
||||
|
||||
const baseArgs = ["--enable-source-maps", path.join(distDir, "cline-core.js")]
|
||||
const workosFetchMockPath = path.join(projectRoot, "scripts", "testing-platform-workos-fetch-mock.cjs")
|
||||
const baseArgs = ["--enable-source-maps", "--require", workosFetchMockPath, path.join(distDir, "cline-core.js")]
|
||||
|
||||
const spawnArgs = USE_C8 ? ["c8", "--report-dir", covDir, "node", ...baseArgs] : ["node", ...baseArgs]
|
||||
const c8Bin = path.join(projectRoot, "node_modules", ".bin", process.platform === "win32" ? "c8.cmd" : "c8")
|
||||
const spawnCommand = USE_C8 ? c8Bin : "node"
|
||||
const spawnArgs = USE_C8 ? ["--report-dir", covDir, "node", ...baseArgs] : baseArgs
|
||||
|
||||
console.log(`Starting Cline Core Service... (useC8=${USE_C8})`)
|
||||
|
||||
const coreService: ChildProcess = spawn("npx", spawnArgs, {
|
||||
const coreService: ChildProcess = spawn(spawnCommand, spawnArgs, {
|
||||
cwd: projectRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Test Orchestrator
|
||||
*
|
||||
* Automates server lifecycle for running spec files against the standalone server.
|
||||
*
|
||||
* Prerequisites:
|
||||
* Build standalone first: `npm run compile-standalone`
|
||||
* Build standalone first: `bun run compile-standalone`
|
||||
*
|
||||
* Usage:
|
||||
* - Single file: `npm run test:tp-orchestrator path/to/spec.json`
|
||||
* - All specs dir: `npm run test:tp-orchestrator tests/specs`
|
||||
* - Single file: `bun run test:tp-orchestrator path/to/spec.json`
|
||||
* - All specs dir: `bun run test:tp-orchestrator tests/specs`
|
||||
*
|
||||
* Flags:
|
||||
* --server-logs Show server logs (hidden by default)
|
||||
@@ -29,7 +29,7 @@ import kill from "tree-kill"
|
||||
let showServerLogs = false
|
||||
let fix = false
|
||||
let coverage = false
|
||||
const WAIT_SERVER_DEFAULT_TIMEOUT = 15000
|
||||
const WAIT_SERVER_DEFAULT_TIMEOUT = 60000
|
||||
const usedPorts = new Set<number>()
|
||||
|
||||
/**
|
||||
@@ -94,8 +94,10 @@ async function startServer(): Promise<{ server: ChildProcess; grpcPort: string }
|
||||
const grpcPort = (await getAvailablePort()).toString()
|
||||
const hostbridgePort = (await getAvailablePort()).toString()
|
||||
|
||||
const server = spawn("npx", ["tsx", "scripts/test-standalone-core-api-server.ts"], {
|
||||
stdio: showServerLogs ? "inherit" : "pipe",
|
||||
const server = spawn("bun", ["scripts/test-standalone-core-api-server.ts"], {
|
||||
// When logs are hidden, ignore stdio instead of piping it without a reader:
|
||||
// an unread pipe can fill and stall server startup/shutdown in CI.
|
||||
stdio: showServerLogs ? "inherit" : "ignore",
|
||||
env: {
|
||||
...process.env,
|
||||
PROTOBUS_PORT: grpcPort,
|
||||
@@ -104,29 +106,50 @@ async function startServer(): Promise<{ server: ChildProcess; grpcPort: string }
|
||||
},
|
||||
})
|
||||
|
||||
// Wait for either the server to become ready or fail on spawn error
|
||||
await Promise.race([
|
||||
waitForPort(Number(grpcPort), "127.0.0.1", WAIT_SERVER_DEFAULT_TIMEOUT),
|
||||
new Promise((_, reject) => server.once("error", reject)),
|
||||
])
|
||||
try {
|
||||
// Wait for either the server to become ready or fail on spawn error.
|
||||
await Promise.race([
|
||||
waitForPort(Number(grpcPort), "127.0.0.1", WAIT_SERVER_DEFAULT_TIMEOUT),
|
||||
new Promise((_, reject) => server.once("error", reject)),
|
||||
new Promise((_, reject) =>
|
||||
server.once("exit", (code, signal) => reject(new Error(`Server exited before ready: ${code ?? signal}`))),
|
||||
),
|
||||
])
|
||||
} catch (error) {
|
||||
await stopServer(server)
|
||||
throw error
|
||||
}
|
||||
|
||||
return { server, grpcPort }
|
||||
}
|
||||
|
||||
function stopServer(server: ChildProcess): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (!server.pid) return resolve()
|
||||
if (!server.pid || server.exitCode !== null || server.signalCode !== null) return resolve()
|
||||
|
||||
let settled = false
|
||||
const finish = () => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
|
||||
server.once("exit", finish)
|
||||
kill(server.pid, "SIGINT", (err) => {
|
||||
if (err) console.warn("Failed to kill server process:", err)
|
||||
server.once("exit", () => resolve())
|
||||
})
|
||||
setTimeout(() => {
|
||||
if (!settled && server.pid) {
|
||||
kill(server.pid, "SIGKILL", finish)
|
||||
}
|
||||
}, 5000).unref()
|
||||
})
|
||||
}
|
||||
|
||||
function runTestingPlatform(specFile: string, grpcPort: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const testProcess = spawn("npx", ["ts-node", "index.ts", specFile, ...(fix ? ["--fix"] : [])], {
|
||||
const testProcess = spawn("bun", ["index.ts", specFile, ...(fix ? ["--fix"] : [])], {
|
||||
cwd: path.join(process.cwd(), "testing-platform"),
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
@@ -209,7 +232,7 @@ async function main() {
|
||||
|
||||
if (!inputPath) {
|
||||
console.error(
|
||||
"Usage: npx tsx scripts/testing-platform-orchestrator.ts <spec-file-or-folder> [--count=N] [--server-logs] [--fix] [--coverage]",
|
||||
"Usage: bun scripts/testing-platform-orchestrator.ts <spec-file-or-folder> [--count=N] [--server-logs] [--fix] [--coverage]",
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Preload used by the standalone testing platform.
|
||||
// It makes the SDK WorkOS device-auth flow deterministic and fully local while
|
||||
// leaving production auth code on the same device-auth path used by users.
|
||||
|
||||
const originalFetch = globalThis.fetch?.bind(globalThis)
|
||||
|
||||
const WORKOS_ORIGIN = "https://api.workos.com"
|
||||
const DEVICE_CODE = "test-device-code"
|
||||
const USER_CODE = "PTBC-TXTP"
|
||||
const ACCESS_TOKEN = "test-personal-token"
|
||||
const REFRESH_TOKEN = "test-personal-token_refresh"
|
||||
|
||||
function jsonResponse(body, init = {}) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: init.status ?? 200,
|
||||
headers: { "Content-Type": "application/json", ...(init.headers ?? {}) },
|
||||
})
|
||||
}
|
||||
|
||||
function inputUrl(input) {
|
||||
if (typeof input === "string") return input
|
||||
if (input instanceof URL) return input.toString()
|
||||
if (input && typeof input === "object" && "url" in input) return input.url
|
||||
return String(input)
|
||||
}
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const urlString = inputUrl(input)
|
||||
let url
|
||||
try {
|
||||
url = new URL(urlString)
|
||||
} catch {
|
||||
return originalFetch(input, init)
|
||||
}
|
||||
|
||||
if (url.origin === WORKOS_ORIGIN && url.pathname === "/user_management/authorize/device") {
|
||||
return jsonResponse({
|
||||
device_code: DEVICE_CODE,
|
||||
user_code: USER_CODE,
|
||||
verification_uri: "https://login.workos.test/device",
|
||||
verification_uri_complete: `https://login.workos.test/device?user_code=${USER_CODE}`,
|
||||
expires_in: 300,
|
||||
interval: 1,
|
||||
})
|
||||
}
|
||||
|
||||
if (url.origin === WORKOS_ORIGIN && url.pathname === "/user_management/authenticate") {
|
||||
return jsonResponse({
|
||||
access_token: ACCESS_TOKEN,
|
||||
refresh_token: REFRESH_TOKEN,
|
||||
token_type: "Bearer",
|
||||
})
|
||||
}
|
||||
|
||||
return originalFetch(input, init)
|
||||
}
|
||||
@@ -1,9 +1,21 @@
|
||||
import { afterEach, beforeEach, describe, it } from "mocha"
|
||||
import { afterEach, beforeEach, describe, it, mock } from "bun:test"
|
||||
import "should"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import * as actualOs from "os"
|
||||
import path from "path"
|
||||
import sinon from "sinon"
|
||||
|
||||
// The SUT does `import * as os from "os"; os.homedir()`. Under bun, sinon's
|
||||
// `stub(os, "homedir")` on the test's own `os` binding does NOT propagate to the
|
||||
// SUT's namespace import, so inject a module-level homedir stub via mock.module
|
||||
// (the rest of `os` — tmpdir() etc. — keeps its real behavior).
|
||||
const homedirStub = sinon.stub()
|
||||
const osMockNamespace = { ...actualOs, homedir: homedirStub }
|
||||
const osMock = () => ({ ...osMockNamespace, default: osMockNamespace })
|
||||
mock.module("os", osMock)
|
||||
mock.module("node:os", osMock)
|
||||
|
||||
import os from "os"
|
||||
import { ClineConfigurationError, ClineEndpoint, ClineEnv, Environment } from "../config"
|
||||
|
||||
describe("ClineEndpoint configuration", () => {
|
||||
@@ -19,11 +31,10 @@ describe("ClineEndpoint configuration", () => {
|
||||
// Create .cline directory
|
||||
await fs.mkdir(path.join(tempDir, ".cline"), { recursive: true })
|
||||
|
||||
// Stub os.homedir to return our temp directory
|
||||
// Stub os.homedir to return our temp directory (via mock.module homedirStub)
|
||||
originalHomedir = os.homedir
|
||||
sandbox
|
||||
.stub(os, "homedir")
|
||||
.returns(tempDir)
|
||||
homedirStub.reset()
|
||||
homedirStub.returns(tempDir)
|
||||
|
||||
// Reset the singleton state using internal method
|
||||
;(ClineEndpoint as any)._instance = null
|
||||
|
||||
@@ -4,13 +4,13 @@ import "./utils/path" // necessary to have access to String.prototype.toPosix
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { StorageContext } from "@/shared/storage/storage-context"
|
||||
import { FileContextTracker } from "./core/context/context-tracking/FileContextTracker"
|
||||
import { clearOnboardingModelsCache } from "./core/controller/models/getClineOnboardingModels"
|
||||
import { HookDiscoveryCache } from "./core/hooks/HookDiscoveryCache"
|
||||
import { HookProcessRegistry } from "./core/hooks/HookProcessRegistry"
|
||||
import { StateManager } from "./core/storage/StateManager"
|
||||
import { AgentConfigLoader } from "./core/task/tools/subagent/AgentConfigLoader"
|
||||
import { ExtensionRegistryInfo } from "./registry"
|
||||
import { registerVsCodeLmHandler } from "./sdk/vscode-lm/register-vscode-lm"
|
||||
import { ErrorService } from "./services/error"
|
||||
import { featureFlagsService } from "./services/feature-flags"
|
||||
import { getDistinctId } from "./services/logging/distinctId"
|
||||
@@ -52,6 +52,11 @@ export async function initialize(storageContext: StorageContext): Promise<Webvie
|
||||
})
|
||||
}
|
||||
|
||||
// Register host-only SDK provider handlers (e.g. VS Code Language Model API),
|
||||
// which depend on the `vscode` module and cannot live in the SDK package.
|
||||
// Must run before any handler is built (standalone utilities or task loop).
|
||||
registerVsCodeLmHandler()
|
||||
|
||||
// =============== External services ===============
|
||||
await ErrorService.initialize()
|
||||
// Initialize PostHog client provider (skip in self-hosted mode)
|
||||
@@ -74,8 +79,6 @@ export async function initialize(storageContext: StorageContext): Promise<Webvie
|
||||
syncWorker().init({ ...blobStoreSettings, userDistinctId: getDistinctId() })
|
||||
// Clean up old temp files in background (non-blocking) and start periodic cleanup every 24 hours
|
||||
ClineTempManager.startPeriodicCleanup()
|
||||
// Clean up orphaned file context warnings (startup cleanup)
|
||||
FileContextTracker.cleanupOrphanedWarnings(stateManager)
|
||||
|
||||
telemetryService.captureExtensionActivated()
|
||||
|
||||
@@ -106,7 +109,7 @@ async function showVersionUpdateAnnouncement(stateManager: StateManager) {
|
||||
})
|
||||
}
|
||||
// Always update the main version tracker for the next launch.
|
||||
await stateManager.setGlobalState("clineVersion", currentVersion)
|
||||
stateManager.setGlobalState("clineVersion", currentVersion)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
|
||||
@@ -4,12 +4,11 @@ import * as path from "path"
|
||||
import { Environment, type EnvironmentConfig } from "./shared/config-types"
|
||||
import { Logger } from "./shared/services/Logger"
|
||||
|
||||
export { Environment, type EnvironmentConfig }
|
||||
|
||||
/**
|
||||
export { Environment } /**
|
||||
* Schema for the endpoints.json configuration file used in on-premise deployments.
|
||||
* All fields are required and must be valid URLs.
|
||||
*/
|
||||
|
||||
interface EndpointsFileSchema {
|
||||
appBaseUrl: string
|
||||
apiBaseUrl: string
|
||||
@@ -36,7 +35,7 @@ class ClineEndpoint {
|
||||
private onPremiseConfig: EndpointsFileSchema | null = null
|
||||
private environment: Environment = Environment.production
|
||||
// Track if config came from bundled file (enterprise distribution)
|
||||
private isBundled: boolean = false
|
||||
private isBundled = false
|
||||
|
||||
private constructor() {
|
||||
// Set environment at module load. Use override if provided.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,606 +0,0 @@
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
|
||||
const APPLY_PATCH_PATCH_REGEX = /\*\*\* Begin Patch\s+([\s\S]*?)\s+\*\*\* End Patch/m
|
||||
|
||||
/**
|
||||
* Convert apply_patch tool calls to write_to_file and replace_in_file format
|
||||
*/
|
||||
export function convertApplyPatchToolCalls(messages: Array<ClineStorageMessage>): Array<ClineStorageMessage> {
|
||||
// Map to track tool_use_id to converted tool info and original input
|
||||
const toolUseIdMap = new Map<string, { name: string; input: any; originalInput: any }>()
|
||||
|
||||
return messages.map((message) => {
|
||||
if (!Array.isArray(message.content)) {
|
||||
return message
|
||||
}
|
||||
|
||||
const convertedContent = message.content.map((block) => {
|
||||
// Handle tool_use blocks
|
||||
if (block.type === "tool_use" && block.name === "apply_patch") {
|
||||
const converted = convertApplyPatchToToolCalls(block.input)
|
||||
// Store the conversion with original input for matching tool_result
|
||||
toolUseIdMap.set(block.id, { ...converted, originalInput: block.input })
|
||||
|
||||
return {
|
||||
...block,
|
||||
name: converted.name,
|
||||
input: converted.input,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tool_result blocks
|
||||
if (block.type === "tool_result") {
|
||||
const conversion = toolUseIdMap.get(block.tool_use_id)
|
||||
if (conversion) {
|
||||
// Reconstruct the tool_result content to match apply_patch format
|
||||
const reconstructedContent = reconstructApplyPatchResult(
|
||||
block,
|
||||
conversion.name,
|
||||
conversion.input,
|
||||
conversion.originalInput,
|
||||
)
|
||||
return {
|
||||
...block,
|
||||
content: reconstructedContent,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return block
|
||||
})
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: convertedContent,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
interface ConvertedTool {
|
||||
name: string
|
||||
input: any
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse apply_patch input and convert to write_to_file or replace_in_file format
|
||||
*/
|
||||
function convertApplyPatchToToolCalls(input: any): ConvertedTool {
|
||||
const patchInput = typeof input === "string" ? input : input?.input || ""
|
||||
|
||||
// Parse the patch format
|
||||
const patchMatch = patchInput.match(APPLY_PATCH_PATCH_REGEX)
|
||||
if (!patchMatch) {
|
||||
// If we can't parse it, return as-is with write_to_file
|
||||
return {
|
||||
name: "write_to_file",
|
||||
input: input,
|
||||
}
|
||||
}
|
||||
|
||||
const patchContent = patchMatch[1]
|
||||
|
||||
// Extract file operation (Add, Update, or Delete)
|
||||
const fileMatch = patchContent.match(/\*\*\* (Add|Update|Delete) File: (.+?)(?:\n|$)/m)
|
||||
if (!fileMatch) {
|
||||
return {
|
||||
name: "write_to_file",
|
||||
input: input,
|
||||
}
|
||||
}
|
||||
|
||||
const action = fileMatch[1]
|
||||
const filePath = fileMatch[2].trim()
|
||||
|
||||
// If it's an Add operation, convert to write_to_file
|
||||
if (action === "Add") {
|
||||
// Extract the content after the file line
|
||||
const contentAfterFile = patchContent.substring(fileMatch.index! + fileMatch[0].length)
|
||||
return {
|
||||
name: "write_to_file",
|
||||
input: {
|
||||
absolutePath: filePath,
|
||||
content: extractNewContentFromPatch(contentAfterFile),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// If it's Update or Delete, convert to replace_in_file
|
||||
if (action === "Update" || action === "Delete") {
|
||||
const diff = convertPatchToDiff(patchContent.substring(fileMatch.index! + fileMatch[0].length))
|
||||
return {
|
||||
name: "replace_in_file",
|
||||
input: {
|
||||
absolutePath: filePath,
|
||||
diff: diff,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return {
|
||||
name: "write_to_file",
|
||||
input: input,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract new content from add operation patch
|
||||
*/
|
||||
function extractNewContentFromPatch(patchContent: string): string {
|
||||
// For Add operations, the patch should contain lines starting with +
|
||||
const lines = patchContent.split("\n")
|
||||
const contentLines: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("+")) {
|
||||
// Remove the + prefix and exactly ONE space if present (but not if it's a tab)
|
||||
let content = line.substring(1)
|
||||
if (content.startsWith(" ") && !content.startsWith("\t")) {
|
||||
content = content.substring(1)
|
||||
}
|
||||
contentLines.push(content)
|
||||
}
|
||||
}
|
||||
|
||||
return contentLines.join("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert V4A patch format to SEARCH/REPLACE format
|
||||
*/
|
||||
function convertPatchToDiff(patchContent: string): string {
|
||||
const diffBlocks: string[] = []
|
||||
const lines = patchContent.split("\n")
|
||||
|
||||
let i = 0
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
|
||||
// Skip empty lines at the start
|
||||
if (!line.trim() && i === 0) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this is the start of a hunk (@@) or a direct change line
|
||||
if (line.trim().startsWith("@@") || line.startsWith("-") || line.startsWith("+")) {
|
||||
const currentSearch: string[] = []
|
||||
const currentReplace: string[] = []
|
||||
|
||||
// Collect @@ context marker lines
|
||||
// @@ prefix marks context lines. If @@something, then "something" is context.
|
||||
// If just @@, then it's an empty context line.
|
||||
while (i < lines.length && lines[i].trim().startsWith("@@")) {
|
||||
const trimmedLine = lines[i].trim()
|
||||
// Extract the actual context content after @@
|
||||
const contextLine = trimmedLine.substring(2)
|
||||
// Always add the context line (even if empty)
|
||||
currentSearch.push(contextLine)
|
||||
currentReplace.push(contextLine)
|
||||
i++
|
||||
}
|
||||
|
||||
if (i >= lines.length) {
|
||||
break
|
||||
}
|
||||
|
||||
// Collect all remaining lines in this hunk until we hit end of content or next @@
|
||||
const hunkLines: string[] = []
|
||||
while (i < lines.length) {
|
||||
// Check if this is a new hunk (starts with @@)
|
||||
if (lines[i].trim().startsWith("@@")) {
|
||||
break
|
||||
}
|
||||
hunkLines.push(lines[i])
|
||||
i++
|
||||
}
|
||||
|
||||
// Now process the hunk to build SEARCH/REPLACE
|
||||
let hasChanges = false
|
||||
for (let j = 0; j < hunkLines.length; j++) {
|
||||
const hunkLine = hunkLines[j]
|
||||
|
||||
if (hunkLine.startsWith("-")) {
|
||||
hasChanges = true
|
||||
// Strip the - prefix and exactly ONE space if present (but not if it's a tab)
|
||||
let content = hunkLine.substring(1)
|
||||
if (content.startsWith(" ") && !content.startsWith(" \t")) {
|
||||
content = content.substring(1)
|
||||
}
|
||||
currentSearch.push(content)
|
||||
} else if (hunkLine.startsWith("+")) {
|
||||
hasChanges = true
|
||||
// Strip the + prefix and exactly ONE space if present (but not if it's a tab)
|
||||
let content = hunkLine.substring(1)
|
||||
if (content.startsWith(" ") && !content.startsWith(" \t")) {
|
||||
content = content.substring(1)
|
||||
}
|
||||
currentReplace.push(content)
|
||||
} else {
|
||||
// Context line without @@ prefix - add to both sides
|
||||
currentSearch.push(hunkLine)
|
||||
currentReplace.push(hunkLine)
|
||||
}
|
||||
}
|
||||
|
||||
// Create the diff block if we have changes
|
||||
if (hasChanges && (currentSearch.length > 0 || currentReplace.length > 0)) {
|
||||
diffBlocks.push(
|
||||
"------- SEARCH\n" +
|
||||
currentSearch.join("\n") +
|
||||
"\n=======\n" +
|
||||
currentReplace.join("\n") +
|
||||
"\n+++++++ REPLACE",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
return diffBlocks.join("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct tool_result content to match apply_patch format by extracting
|
||||
* the final file content and converting it back to V4A patch format
|
||||
*/
|
||||
function reconstructApplyPatchResult(
|
||||
block: any,
|
||||
convertedToolName: string,
|
||||
_convertedInput: any,
|
||||
originalInput: any,
|
||||
): string | any[] {
|
||||
// Extract the content from the tool_result
|
||||
const content = typeof block.content === "string" ? block.content : ""
|
||||
|
||||
// Try to extract the final_file_content
|
||||
const finalContentMatch = content.match(/<final_file_content path="([^"]+)">\s*([\s\S]*?)\s*<\/final_file_content>/)
|
||||
|
||||
if (!finalContentMatch) {
|
||||
// If no final_file_content found, return original content
|
||||
return block.content
|
||||
}
|
||||
|
||||
const filePath = finalContentMatch[1]
|
||||
const finalContent = finalContentMatch[2]
|
||||
|
||||
// Reconstruct the result message based on the converted tool type
|
||||
if (convertedToolName === "write_to_file") {
|
||||
// For write_to_file, we just need to confirm the file was created/written
|
||||
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully saved to ${filePath}.\n\nThe file has been created/updated with the new content.`
|
||||
}
|
||||
|
||||
if (convertedToolName === "replace_in_file") {
|
||||
// For replace_in_file, we need to reconstruct the V4A patch format result
|
||||
// Try to parse the original patch to get the action and build context
|
||||
const patchInput = typeof originalInput === "string" ? originalInput : originalInput?.input || ""
|
||||
const patchMatch = patchInput.match(APPLY_PATCH_PATCH_REGEX)
|
||||
|
||||
if (patchMatch) {
|
||||
const patchContent = patchMatch[1]
|
||||
const fileMatch = patchContent.match(/\*\*\* (Add|Update|Delete) File: (.+?)(?:\n|$)/m)
|
||||
|
||||
if (fileMatch) {
|
||||
const action = fileMatch[1]
|
||||
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully updated in ${filePath}.\n\nThe file has been modified using ${action} operation.\n\n<final_file_content path="${filePath}">\n${finalContent}\n</final_file_content>\n\nIMPORTANT: For any future changes to this file, use the final_file_content shown above as your reference.`
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for replace_in_file
|
||||
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully updated in ${filePath}.\n\n<final_file_content path="${filePath}">\n${finalContent}\n</final_file_content>\n\nIMPORTANT: For any future changes to this file, use the final_file_content shown above as your reference.`
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return block.content
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert write_to_file and replace_in_file tool calls to apply_patch format
|
||||
*/
|
||||
export function convertWriteToFileToolCalls(messages: Array<ClineStorageMessage>): Array<ClineStorageMessage> {
|
||||
// Map to track tool_use_id to converted tool info and original input
|
||||
const toolUseIdMap = new Map<string, { originalName: string; originalInput: any; patchInput?: string }>()
|
||||
|
||||
// First pass: collect tool_use blocks
|
||||
for (const message of messages) {
|
||||
if (!Array.isArray(message.content)) {
|
||||
continue
|
||||
}
|
||||
for (const block of message.content) {
|
||||
if (block.type === "tool_use" && (block.name === "write_to_file" || block.name === "replace_in_file")) {
|
||||
toolUseIdMap.set(block.id, {
|
||||
originalName: block.name,
|
||||
originalInput: block.input,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: find tool_results and extract final content to build proper patches
|
||||
const finalContentMap = new Map<string, string>()
|
||||
for (const message of messages) {
|
||||
if (!Array.isArray(message.content)) {
|
||||
continue
|
||||
}
|
||||
for (const block of message.content) {
|
||||
if (block.type === "tool_result" && toolUseIdMap.has(block.tool_use_id)) {
|
||||
const content = typeof block.content === "string" ? block.content : ""
|
||||
const finalContentMatch = content.match(
|
||||
/<final_file_content path="([^"]+)">\s*([\s\S]*?)\s*<\/final_file_content>/,
|
||||
)
|
||||
if (finalContentMatch) {
|
||||
finalContentMap.set(block.tool_use_id, finalContentMatch[2])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Third pass: convert messages
|
||||
return messages.map((message) => {
|
||||
if (!Array.isArray(message.content)) {
|
||||
return message
|
||||
}
|
||||
|
||||
const convertedContent = message.content.map((block) => {
|
||||
// Handle tool_use blocks for write_to_file and replace_in_file
|
||||
if (block.type === "tool_use" && (block.name === "write_to_file" || block.name === "replace_in_file")) {
|
||||
const finalContent = finalContentMap.get(block.id)
|
||||
const patchInput = convertToPatchFormat(block.name, block.input, finalContent)
|
||||
|
||||
// Update the map with the generated patch
|
||||
const existingEntry = toolUseIdMap.get(block.id)
|
||||
if (existingEntry) {
|
||||
existingEntry.patchInput = patchInput
|
||||
}
|
||||
|
||||
return {
|
||||
...block,
|
||||
name: "apply_patch",
|
||||
input: {
|
||||
input: patchInput,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tool_result blocks
|
||||
if (block.type === "tool_result") {
|
||||
const conversion = toolUseIdMap.get(block.tool_use_id)
|
||||
if (conversion) {
|
||||
// Reconstruct the tool_result content to match apply_patch format
|
||||
const reconstructedContent = reconstructWriteToFileResult(
|
||||
block,
|
||||
conversion.originalName,
|
||||
conversion.originalInput,
|
||||
)
|
||||
return {
|
||||
...block,
|
||||
content: reconstructedContent,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return block
|
||||
})
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: convertedContent,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert write_to_file or replace_in_file input to apply_patch format
|
||||
*/
|
||||
function convertToPatchFormat(toolName: string, input: any, finalContent?: string): string {
|
||||
const filePath = input.absolutePath || input.path || ""
|
||||
|
||||
if (toolName === "write_to_file") {
|
||||
// Convert write_to_file to Add operation
|
||||
const content = input.content || ""
|
||||
const lines = content.split("\n")
|
||||
const patchLines = ["@@"]
|
||||
patchLines.push(...lines.map((line: string) => `+ ${line}`))
|
||||
|
||||
return `apply_patch <<"EOF"
|
||||
*** Begin Patch
|
||||
*** Add File: ${filePath}
|
||||
${patchLines.join("\n")}
|
||||
*** End Patch
|
||||
EOF`
|
||||
}
|
||||
|
||||
if (toolName === "replace_in_file") {
|
||||
// Convert replace_in_file to Update operation
|
||||
const diff = input.diff || ""
|
||||
|
||||
// Parse SEARCH/REPLACE blocks and convert to V4A format with context
|
||||
const patchContent = convertDiffToPatchWithContext(diff, finalContent)
|
||||
|
||||
return `apply_patch <<"EOF"
|
||||
*** Begin Patch
|
||||
*** Update File: ${filePath}
|
||||
${patchContent}
|
||||
*** End Patch
|
||||
EOF`
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert SEARCH/REPLACE diff format to V4A patch format with additional context from final content
|
||||
*/
|
||||
function convertDiffToPatchWithContext(diff: string, finalContent?: string): string {
|
||||
const patchLines: string[] = []
|
||||
|
||||
// Match all SEARCH/REPLACE blocks
|
||||
const blockRegex = /------- SEARCH\s*\n([\s\S]*?)\n=======\s*\n([\s\S]*?)\n\+{7} REPLACE/g
|
||||
let match
|
||||
|
||||
while ((match = blockRegex.exec(diff)) !== null) {
|
||||
const searchContent = match[1]
|
||||
const replaceContent = match[2]
|
||||
|
||||
const searchLines = searchContent.split("\n")
|
||||
const replaceLines = replaceContent.split("\n")
|
||||
|
||||
// Find common prefix and suffix between search and replace
|
||||
let prefixEnd = 0
|
||||
while (
|
||||
prefixEnd < searchLines.length &&
|
||||
prefixEnd < replaceLines.length &&
|
||||
searchLines[prefixEnd] === replaceLines[prefixEnd]
|
||||
) {
|
||||
prefixEnd++
|
||||
}
|
||||
|
||||
let suffixStart = searchLines.length
|
||||
let replaceSuffixStart = replaceLines.length
|
||||
while (
|
||||
suffixStart > prefixEnd &&
|
||||
replaceSuffixStart > prefixEnd &&
|
||||
searchLines[suffixStart - 1] === replaceLines[replaceSuffixStart - 1]
|
||||
) {
|
||||
suffixStart--
|
||||
replaceSuffixStart--
|
||||
}
|
||||
|
||||
// If we have finalContent, extract additional context from it
|
||||
if (finalContent) {
|
||||
const finalLines = finalContent.split("\n")
|
||||
|
||||
// Find where the replaced content appears in the final file
|
||||
let matchIndex = -1
|
||||
for (let i = 0; i < finalLines.length; i++) {
|
||||
// Try to match the first replace line
|
||||
if (replaceLines.length > 0 && finalLines[i] === replaceLines[0]) {
|
||||
// Check if subsequent lines also match
|
||||
let allMatch = true
|
||||
for (let j = 1; j < replaceLines.length && i + j < finalLines.length; j++) {
|
||||
if (finalLines[i + j] !== replaceLines[j]) {
|
||||
allMatch = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (allMatch) {
|
||||
matchIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matchIndex >= 0) {
|
||||
// Extract up to 3 lines before as context
|
||||
const contextStart = Math.max(0, matchIndex - 3)
|
||||
const contextLines: string[] = []
|
||||
for (let i = contextStart; i < matchIndex; i++) {
|
||||
contextLines.push(finalLines[i])
|
||||
}
|
||||
|
||||
// Pad to 3 lines if needed (with empty strings)
|
||||
while (contextLines.length < 3) {
|
||||
contextLines.unshift("")
|
||||
}
|
||||
|
||||
// Add @@ marker with the first context line
|
||||
if (contextLines[0] === "") {
|
||||
patchLines.push("@@")
|
||||
} else {
|
||||
patchLines.push(`@@${contextLines[0]}`)
|
||||
}
|
||||
|
||||
// Add remaining context lines (without @@ marker)
|
||||
for (let i = 1; i < contextLines.length; i++) {
|
||||
patchLines.push(contextLines[i])
|
||||
}
|
||||
|
||||
// Add common prefix lines (without +/- markers)
|
||||
for (let i = 0; i < prefixEnd; i++) {
|
||||
patchLines.push(searchLines[i])
|
||||
}
|
||||
|
||||
// Add the actual changes (lines that differ)
|
||||
for (let i = prefixEnd; i < suffixStart; i++) {
|
||||
patchLines.push(`- ${searchLines[i]}`)
|
||||
}
|
||||
for (let i = prefixEnd; i < replaceSuffixStart; i++) {
|
||||
patchLines.push(`+ ${replaceLines[i]}`)
|
||||
}
|
||||
|
||||
// Add common suffix lines (without +/- markers)
|
||||
for (let i = suffixStart; i < searchLines.length; i++) {
|
||||
patchLines.push(searchLines[i])
|
||||
}
|
||||
|
||||
// Extract up to 3 lines after as trailing context (without @@ markers)
|
||||
const contextEnd = Math.min(finalLines.length, matchIndex + replaceLines.length + 3)
|
||||
for (let i = matchIndex + replaceLines.length; i < contextEnd; i++) {
|
||||
patchLines.push(finalLines[i])
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if no finalContent or couldn't find match, use the prefix/suffix from SEARCH/REPLACE
|
||||
patchLines.push("@@")
|
||||
|
||||
// Add common prefix lines (without +/- markers)
|
||||
for (let i = 0; i < prefixEnd; i++) {
|
||||
patchLines.push(searchLines[i])
|
||||
}
|
||||
|
||||
// Add the actual changes (lines that differ)
|
||||
for (let i = prefixEnd; i < suffixStart; i++) {
|
||||
patchLines.push(`- ${searchLines[i]}`)
|
||||
}
|
||||
for (let i = prefixEnd; i < replaceSuffixStart; i++) {
|
||||
patchLines.push(`+ ${replaceLines[i]}`)
|
||||
}
|
||||
|
||||
// Add common suffix lines (without +/- markers)
|
||||
for (let i = suffixStart; i < searchLines.length; i++) {
|
||||
patchLines.push(searchLines[i])
|
||||
}
|
||||
}
|
||||
|
||||
return patchLines.join("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct tool_result content to match apply_patch result format
|
||||
*/
|
||||
function reconstructWriteToFileResult(block: any, originalToolName: string, originalInput: any): string | any[] {
|
||||
// Extract the content from the tool_result
|
||||
const content = typeof block.content === "string" ? block.content : ""
|
||||
|
||||
// Try to extract the final_file_content
|
||||
const finalContentMatch = content.match(/<final_file_content path="([^"]+)">\s*([\s\S]*?)\s*<\/final_file_content>/)
|
||||
|
||||
const filePath = originalInput.absolutePath || originalInput.path || ""
|
||||
|
||||
if (!finalContentMatch) {
|
||||
// If no final_file_content found, create a simple success message
|
||||
if (originalToolName === "write_to_file") {
|
||||
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully saved to ${filePath}.\n\nThe file has been created/updated with the new content.`
|
||||
} else {
|
||||
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully updated in ${filePath}.\n\nThe file has been modified.`
|
||||
}
|
||||
}
|
||||
|
||||
const finalContent = finalContentMatch[2]
|
||||
|
||||
// Reconstruct the result message based on the original tool type
|
||||
if (originalToolName === "write_to_file") {
|
||||
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully saved to ${filePath}.\n\nThe file has been created/updated with the new content.`
|
||||
}
|
||||
|
||||
if (originalToolName === "replace_in_file") {
|
||||
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully updated in ${filePath}.\n\nThe file has been modified using Update operation.\n\n<final_file_content path="${filePath}">\n${finalContent}\n</final_file_content>\n\nIMPORTANT: For any future changes to this file, use the final_file_content shown above as your reference.`
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return block.content
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { ClineDefaultTool } from "@/shared/tools"
|
||||
import { convertApplyPatchToolCalls, convertWriteToFileToolCalls } from "./diff-editors"
|
||||
|
||||
/**
|
||||
* Transforms tool call messages between different tool formats based on native tool support.
|
||||
* Converts between apply_patch and write_to_file/replace_in_file formats as needed.
|
||||
*
|
||||
* @param clineMessages - Array of messages containing tool calls to transform
|
||||
* @param nativeTools - Array of tools natively supported by the current provider
|
||||
* @returns Transformed messages array, or original if no transformation needed
|
||||
*/
|
||||
export function transformToolCallMessages(
|
||||
clineMessages: ClineStorageMessage[],
|
||||
nativeTools?: ClineDefaultTool[],
|
||||
): ClineStorageMessage[] {
|
||||
// Early return if no messages or native tools provided
|
||||
if (!clineMessages?.length || !nativeTools?.length) {
|
||||
return clineMessages
|
||||
}
|
||||
|
||||
// Create Sets for O(1) lookup performance
|
||||
const nativeToolSet = new Set(nativeTools)
|
||||
const usedToolSet = new Set<string>()
|
||||
|
||||
// Single pass: collect all tools used in assistant messages
|
||||
for (const msg of clineMessages) {
|
||||
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "tool_use" && block.name) {
|
||||
usedToolSet.add(block.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Early return if no tools were used
|
||||
if (usedToolSet.size === 0) {
|
||||
return clineMessages
|
||||
}
|
||||
|
||||
// Determine which conversion to apply
|
||||
const hasApplyPatchNative = nativeToolSet.has(ClineDefaultTool.APPLY_PATCH)
|
||||
const hasFileEditNative = nativeToolSet.has(ClineDefaultTool.FILE_EDIT) || nativeToolSet.has(ClineDefaultTool.FILE_NEW)
|
||||
|
||||
const hasApplyPatchUsed = usedToolSet.has(ClineDefaultTool.APPLY_PATCH)
|
||||
const hasFileEditUsed = usedToolSet.has(ClineDefaultTool.FILE_EDIT) || usedToolSet.has(ClineDefaultTool.FILE_NEW)
|
||||
|
||||
// Convert write_to_file/replace_in_file → apply_patch
|
||||
if (hasApplyPatchNative && hasFileEditUsed) {
|
||||
return convertWriteToFileToolCalls(clineMessages)
|
||||
}
|
||||
|
||||
// Convert apply_patch → write_to_file/replace_in_file
|
||||
if (hasFileEditNative && hasApplyPatchUsed) {
|
||||
return convertApplyPatchToolCalls(clineMessages)
|
||||
}
|
||||
|
||||
return clineMessages
|
||||
}
|
||||
@@ -1,72 +1,18 @@
|
||||
import {
|
||||
ApiConfiguration,
|
||||
buildModelInfoNameMap,
|
||||
clinePassDefaultModelId,
|
||||
ModelInfo,
|
||||
QwenApiRegions,
|
||||
resolveClinePassModelInfo,
|
||||
} from "@shared/api"
|
||||
import { ModelInfo } from "@shared/api"
|
||||
import { Mode } from "@shared/storage/types"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { ClineTool } from "@/shared/tools"
|
||||
import { AIhubmixHandler } from "./providers/aihubmix"
|
||||
import { AnthropicHandler } from "./providers/anthropic"
|
||||
import { AskSageHandler } from "./providers/asksage"
|
||||
import { BasetenHandler } from "./providers/baseten"
|
||||
import { AwsBedrockHandler } from "./providers/bedrock"
|
||||
import { CerebrasHandler } from "./providers/cerebras"
|
||||
import { ClaudeCodeHandler } from "./providers/claude-code"
|
||||
import { ClineHandler } from "./providers/cline"
|
||||
import { DeepSeekHandler } from "./providers/deepseek"
|
||||
import { DifyHandler } from "./providers/dify"
|
||||
import { DoubaoHandler } from "./providers/doubao"
|
||||
import { FireworksHandler } from "./providers/fireworks"
|
||||
import { GeminiHandler } from "./providers/gemini"
|
||||
import { GroqHandler } from "./providers/groq"
|
||||
import { HicapHandler } from "./providers/hicap"
|
||||
import { HuaweiCloudMaaSHandler } from "./providers/huawei-cloud-maas"
|
||||
import { HuggingFaceHandler } from "./providers/huggingface"
|
||||
import { LiteLlmHandler } from "./providers/litellm"
|
||||
import { LmStudioHandler } from "./providers/lmstudio"
|
||||
import { MinimaxHandler } from "./providers/minimax"
|
||||
import { MistralHandler } from "./providers/mistral"
|
||||
import { MoonshotHandler } from "./providers/moonshot"
|
||||
import { NebiusHandler } from "./providers/nebius"
|
||||
import { NousResearchHandler } from "./providers/nousresearch"
|
||||
import { OcaHandler } from "./providers/oca"
|
||||
import { OllamaHandler } from "./providers/ollama"
|
||||
import { OpenAiHandler } from "./providers/openai"
|
||||
import { OpenAiCodexHandler } from "./providers/openai-codex"
|
||||
import { OpenAiNativeHandler } from "./providers/openai-native"
|
||||
import { OpenRouterHandler } from "./providers/openrouter"
|
||||
import { QwenHandler } from "./providers/qwen"
|
||||
import { QwenCodeHandler } from "./providers/qwen-code"
|
||||
import { RequestyHandler } from "./providers/requesty"
|
||||
import { SambanovaHandler } from "./providers/sambanova"
|
||||
import { SapAiCoreHandler } from "./providers/sapaicore"
|
||||
import { TogetherHandler } from "./providers/together"
|
||||
import { VercelAIGatewayHandler } from "./providers/vercel-ai-gateway"
|
||||
import { VertexHandler } from "./providers/vertex"
|
||||
import { VsCodeLmHandler } from "./providers/vscode-lm"
|
||||
import { WandbHandler } from "./providers/wandb"
|
||||
import { XAIHandler } from "./providers/xai"
|
||||
import { ZAiHandler } from "./providers/zai"
|
||||
import { ApiStream, ApiStreamUsageChunk } from "./transform/stream"
|
||||
|
||||
export type CommonApiHandlerOptions = {
|
||||
onRetryAttempt?: ApiConfiguration["onRetryAttempt"]
|
||||
}
|
||||
export interface ApiHandler {
|
||||
createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: ClineTool[], useResponseApi?: boolean): ApiStream
|
||||
getModel(): ApiHandlerModel
|
||||
getApiStreamUsage?(): Promise<ApiStreamUsageChunk | undefined>
|
||||
abort?(): void
|
||||
}
|
||||
// buildApiHandler now routes inference through the Cline SDK. It lives in
|
||||
// apps/vscode/src/sdk/sdk-api-handler.ts and callers import it directly from
|
||||
// there. It is deliberately NOT re-exported here: this barrel is imported
|
||||
// widely for *types* only, and re-exporting a value from the SDK module would
|
||||
// pull the entire SDK/session-factory runtime graph into every type importer
|
||||
// at module-eval time (which can break extension activation). Keep this file
|
||||
// types-only.
|
||||
|
||||
export interface ApiHandlerModel {
|
||||
id: string
|
||||
info: ModelInfo
|
||||
providerId?: string
|
||||
}
|
||||
|
||||
export interface ApiProviderInfo {
|
||||
@@ -75,470 +21,3 @@ export interface ApiProviderInfo {
|
||||
mode: Mode
|
||||
customPrompt?: string // "compact"
|
||||
}
|
||||
|
||||
export interface SingleCompletionHandler {
|
||||
completePrompt(prompt: string): Promise<string>
|
||||
}
|
||||
|
||||
function createHandlerForProvider(
|
||||
apiProvider: string | undefined,
|
||||
options: Omit<ApiConfiguration, "apiProvider">,
|
||||
mode: Mode,
|
||||
): ApiHandler {
|
||||
switch (apiProvider) {
|
||||
case "anthropic":
|
||||
return new AnthropicHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
apiKey: options.apiKey,
|
||||
anthropicBaseUrl: options.anthropicBaseUrl,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
})
|
||||
case "openrouter":
|
||||
return new OpenRouterHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
openRouterApiKey: options.openRouterApiKey,
|
||||
openRouterModelId: mode === "plan" ? options.planModeOpenRouterModelId : options.actModeOpenRouterModelId,
|
||||
openRouterModelInfo: mode === "plan" ? options.planModeOpenRouterModelInfo : options.actModeOpenRouterModelInfo,
|
||||
openRouterProviderSorting: options.openRouterProviderSorting,
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
enableParallelToolCalling: options.enableParallelToolCalling,
|
||||
})
|
||||
case "bedrock":
|
||||
return new AwsBedrockHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
awsAccessKey: options.awsAccessKey,
|
||||
awsSecretKey: options.awsSecretKey,
|
||||
awsSessionToken: options.awsSessionToken,
|
||||
awsRegion: options.awsRegion,
|
||||
awsAuthentication: options.awsAuthentication,
|
||||
awsBedrockApiKey: options.awsBedrockApiKey,
|
||||
awsUseCrossRegionInference: options.awsUseCrossRegionInference,
|
||||
awsUseGlobalInference: options.awsUseGlobalInference,
|
||||
awsBedrockUsePromptCache: options.awsBedrockUsePromptCache,
|
||||
awsUseProfile: options.awsUseProfile,
|
||||
awsProfile: options.awsProfile,
|
||||
awsBedrockEndpoint: options.awsBedrockEndpoint,
|
||||
awsBedrockCustomSelected:
|
||||
mode === "plan" ? options.planModeAwsBedrockCustomSelected : options.actModeAwsBedrockCustomSelected,
|
||||
awsBedrockCustomModelBaseId:
|
||||
mode === "plan" ? options.planModeAwsBedrockCustomModelBaseId : options.actModeAwsBedrockCustomModelBaseId,
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
})
|
||||
case "vertex":
|
||||
return new VertexHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
vertexProjectId: options.vertexProjectId,
|
||||
vertexRegion: options.vertexRegion,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
geminiApiKey: options.geminiApiKey,
|
||||
geminiBaseUrl: options.geminiBaseUrl,
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
ulid: options.ulid,
|
||||
})
|
||||
case "openai":
|
||||
return new OpenAiHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
openAiApiKey: options.openAiApiKey,
|
||||
openAiBaseUrl: options.openAiBaseUrl,
|
||||
azureApiVersion: options.azureApiVersion,
|
||||
azureIdentity: options.azureIdentity,
|
||||
openAiHeaders: options.openAiHeaders,
|
||||
openAiModelId: mode === "plan" ? options.planModeOpenAiModelId : options.actModeOpenAiModelId,
|
||||
openAiModelInfo: mode === "plan" ? options.planModeOpenAiModelInfo : options.actModeOpenAiModelInfo,
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
})
|
||||
case "ollama":
|
||||
return new OllamaHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
ollamaBaseUrl: options.ollamaBaseUrl,
|
||||
ollamaApiKey: options.ollamaApiKey,
|
||||
ollamaModelId: mode === "plan" ? options.planModeOllamaModelId : options.actModeOllamaModelId,
|
||||
ollamaApiOptionsCtxNum: options.ollamaApiOptionsCtxNum,
|
||||
requestTimeoutMs: options.requestTimeoutMs,
|
||||
})
|
||||
case "lmstudio":
|
||||
return new LmStudioHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
lmStudioBaseUrl: options.lmStudioBaseUrl,
|
||||
lmStudioModelId: mode === "plan" ? options.planModeLmStudioModelId : options.actModeLmStudioModelId,
|
||||
lmStudioMaxTokens: options.lmStudioMaxTokens,
|
||||
})
|
||||
case "gemini":
|
||||
return new GeminiHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
vertexProjectId: options.vertexProjectId,
|
||||
vertexRegion: options.vertexRegion,
|
||||
geminiApiKey: options.geminiApiKey,
|
||||
geminiBaseUrl: options.geminiBaseUrl,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
ulid: options.ulid,
|
||||
})
|
||||
case "openai-native":
|
||||
return new OpenAiNativeHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
openAiNativeApiKey: options.openAiNativeApiKey,
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
})
|
||||
case "openai-codex":
|
||||
return new OpenAiCodexHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
})
|
||||
case "deepseek":
|
||||
return new DeepSeekHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
deepSeekApiKey: options.deepSeekApiKey,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
})
|
||||
case "requesty":
|
||||
return new RequestyHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
requestyBaseUrl: options.requestyBaseUrl,
|
||||
requestyApiKey: options.requestyApiKey,
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
requestyModelId: mode === "plan" ? options.planModeRequestyModelId : options.actModeRequestyModelId,
|
||||
requestyModelInfo: mode === "plan" ? options.planModeRequestyModelInfo : options.actModeRequestyModelInfo,
|
||||
})
|
||||
case "fireworks":
|
||||
return new FireworksHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
fireworksApiKey: options.fireworksApiKey,
|
||||
fireworksModelId: mode === "plan" ? options.planModeFireworksModelId : options.actModeFireworksModelId,
|
||||
})
|
||||
case "together":
|
||||
return new TogetherHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
togetherApiKey: options.togetherApiKey,
|
||||
togetherModelId: mode === "plan" ? options.planModeTogetherModelId : options.actModeTogetherModelId,
|
||||
})
|
||||
case "qwen":
|
||||
return new QwenHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
qwenApiKey: options.qwenApiKey,
|
||||
qwenApiLine:
|
||||
options.qwenApiLine === QwenApiRegions.INTERNATIONAL ? QwenApiRegions.INTERNATIONAL : QwenApiRegions.CHINA,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
})
|
||||
case "qwen-code":
|
||||
return new QwenCodeHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
qwenCodeOauthPath: options.qwenCodeOauthPath,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
})
|
||||
case "doubao":
|
||||
return new DoubaoHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
doubaoApiKey: options.doubaoApiKey,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
})
|
||||
case "mistral":
|
||||
return new MistralHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
mistralApiKey: options.mistralApiKey,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
})
|
||||
case "vscode-lm":
|
||||
return new VsCodeLmHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
vsCodeLmModelSelector:
|
||||
mode === "plan" ? options.planModeVsCodeLmModelSelector : options.actModeVsCodeLmModelSelector,
|
||||
})
|
||||
case "cline": {
|
||||
const configuredClineModelId = mode === "plan" ? options.planModeClineModelId : options.actModeClineModelId
|
||||
const configuredClineModelInfo = mode === "plan" ? options.planModeClineModelInfo : options.actModeClineModelInfo
|
||||
const clineModelId =
|
||||
configuredClineModelId || (mode === "plan" ? options.planModeOpenRouterModelId : options.actModeOpenRouterModelId)
|
||||
const clineModelInfo =
|
||||
configuredClineModelInfo ||
|
||||
(mode === "plan" ? options.planModeOpenRouterModelInfo : options.actModeOpenRouterModelInfo)
|
||||
return new ClineHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
clineAccountId: options.clineAccountId,
|
||||
clineApiKey: options.clineApiKey,
|
||||
ulid: options.ulid,
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
openRouterProviderSorting: options.openRouterProviderSorting,
|
||||
openRouterModelId: clineModelId,
|
||||
openRouterModelInfo: clineModelInfo,
|
||||
enableParallelToolCalling: options.enableParallelToolCalling,
|
||||
})
|
||||
}
|
||||
case "cline-pass": {
|
||||
const configuredClinePassModelId =
|
||||
mode === "plan" ? options.planModeClinePassModelId : options.actModeClinePassModelId
|
||||
const configuredClinePassModelInfo =
|
||||
mode === "plan" ? options.planModeClinePassModelInfo : options.actModeClinePassModelInfo
|
||||
const clineModelId = configuredClinePassModelId?.startsWith("cline-pass/")
|
||||
? configuredClinePassModelId
|
||||
: clinePassDefaultModelId
|
||||
const clineModelInfo = resolveClinePassModelInfo(
|
||||
clineModelId,
|
||||
configuredClinePassModelInfo
|
||||
? buildModelInfoNameMap({ [clineModelId]: configuredClinePassModelInfo })
|
||||
: undefined,
|
||||
)
|
||||
return new ClineHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
clineAccountId: options.clineAccountId,
|
||||
clineApiKey: options.clineApiKey,
|
||||
ulid: options.ulid,
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
openRouterProviderSorting: options.openRouterProviderSorting,
|
||||
openRouterModelId: clineModelId,
|
||||
openRouterModelInfo: clineModelInfo,
|
||||
enableParallelToolCalling: options.enableParallelToolCalling,
|
||||
})
|
||||
}
|
||||
case "litellm":
|
||||
return new LiteLlmHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
liteLlmApiKey: options.liteLlmApiKey,
|
||||
liteLlmBaseUrl: options.liteLlmBaseUrl,
|
||||
liteLlmModelId: mode === "plan" ? options.planModeLiteLlmModelId : options.actModeLiteLlmModelId,
|
||||
liteLlmModelInfo: mode === "plan" ? options.planModeLiteLlmModelInfo : options.actModeLiteLlmModelInfo,
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
liteLlmUsePromptCache: options.liteLlmUsePromptCache,
|
||||
ulid: options.ulid,
|
||||
})
|
||||
case "moonshot":
|
||||
return new MoonshotHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
moonshotApiKey: options.moonshotApiKey,
|
||||
moonshotApiLine: options.moonshotApiLine,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
})
|
||||
case "huggingface":
|
||||
return new HuggingFaceHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
huggingFaceApiKey: options.huggingFaceApiKey,
|
||||
huggingFaceModelId: mode === "plan" ? options.planModeHuggingFaceModelId : options.actModeHuggingFaceModelId,
|
||||
huggingFaceModelInfo:
|
||||
mode === "plan" ? options.planModeHuggingFaceModelInfo : options.actModeHuggingFaceModelInfo,
|
||||
})
|
||||
case "nebius":
|
||||
return new NebiusHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
nebiusApiKey: options.nebiusApiKey,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
})
|
||||
case "asksage":
|
||||
return new AskSageHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
asksageApiKey: options.asksageApiKey,
|
||||
asksageApiUrl: options.asksageApiUrl,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
})
|
||||
case "xai":
|
||||
return new XAIHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
xaiApiKey: options.xaiApiKey,
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
})
|
||||
case "sambanova":
|
||||
return new SambanovaHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
sambanovaApiKey: options.sambanovaApiKey,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
})
|
||||
case "cerebras":
|
||||
return new CerebrasHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
cerebrasApiKey: options.cerebrasApiKey,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
})
|
||||
case "groq":
|
||||
return new GroqHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
groqApiKey: options.groqApiKey,
|
||||
groqModelId: mode === "plan" ? options.planModeGroqModelId : options.actModeGroqModelId,
|
||||
groqModelInfo: mode === "plan" ? options.planModeGroqModelInfo : options.actModeGroqModelInfo,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
})
|
||||
case "baseten":
|
||||
return new BasetenHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
basetenApiKey: options.basetenApiKey,
|
||||
basetenModelId: mode === "plan" ? options.planModeBasetenModelId : options.actModeBasetenModelId,
|
||||
basetenModelInfo: mode === "plan" ? options.planModeBasetenModelInfo : options.actModeBasetenModelInfo,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
})
|
||||
case "sapaicore":
|
||||
return new SapAiCoreHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
sapAiCoreClientId: options.sapAiCoreClientId,
|
||||
sapAiCoreClientSecret: options.sapAiCoreClientSecret,
|
||||
sapAiCoreTokenUrl: options.sapAiCoreTokenUrl,
|
||||
sapAiResourceGroup: options.sapAiResourceGroup,
|
||||
sapAiCoreBaseUrl: options.sapAiCoreBaseUrl,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
deploymentId: mode === "plan" ? options.planModeSapAiCoreDeploymentId : options.actModeSapAiCoreDeploymentId,
|
||||
sapAiCoreUseOrchestrationMode: options.sapAiCoreUseOrchestrationMode,
|
||||
})
|
||||
case "claude-code":
|
||||
return new ClaudeCodeHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
claudeCodePath: options.claudeCodePath,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
})
|
||||
case "huawei-cloud-maas":
|
||||
return new HuaweiCloudMaaSHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
huaweiCloudMaasApiKey: options.huaweiCloudMaasApiKey,
|
||||
huaweiCloudMaasModelId:
|
||||
mode === "plan" ? options.planModeHuaweiCloudMaasModelId : options.actModeHuaweiCloudMaasModelId,
|
||||
huaweiCloudMaasModelInfo:
|
||||
mode === "plan" ? options.planModeHuaweiCloudMaasModelInfo : options.actModeHuaweiCloudMaasModelInfo,
|
||||
})
|
||||
case "dify": // Add Dify.ai handler
|
||||
return new DifyHandler({
|
||||
difyApiKey: options.difyApiKey,
|
||||
difyBaseUrl: options.difyBaseUrl,
|
||||
})
|
||||
case "vercel-ai-gateway":
|
||||
return new VercelAIGatewayHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
vercelAiGatewayApiKey: options.vercelAiGatewayApiKey,
|
||||
openRouterModelId:
|
||||
mode === "plan" ? options.planModeVercelAiGatewayModelId : options.actModeVercelAiGatewayModelId,
|
||||
openRouterModelInfo:
|
||||
mode === "plan" ? options.planModeVercelAiGatewayModelInfo : options.actModeVercelAiGatewayModelInfo,
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
})
|
||||
case "zai":
|
||||
return new ZAiHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
zaiApiLine: options.zaiApiLine,
|
||||
zaiApiKey: options.zaiApiKey,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
})
|
||||
case "oca":
|
||||
return new OcaHandler({
|
||||
ocaMode: options.ocaMode || "internal",
|
||||
ocaBaseUrl: options.ocaBaseUrl,
|
||||
ocaModelId: mode === "plan" ? options.planModeOcaModelId : options.actModeOcaModelId,
|
||||
ocaModelInfo: mode === "plan" ? options.planModeOcaModelInfo : options.actModeOcaModelInfo,
|
||||
ocaReasoningEffort: mode === "plan" ? options.planModeOcaReasoningEffort : options.actModeOcaReasoningEffort,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
ocaUsePromptCache:
|
||||
mode === "plan"
|
||||
? options.planModeOcaModelInfo?.supportsPromptCache
|
||||
: options.actModeOcaModelInfo?.supportsPromptCache,
|
||||
taskId: options.ulid,
|
||||
})
|
||||
case "aihubmix":
|
||||
return new AIhubmixHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
apiKey: options.aihubmixApiKey,
|
||||
baseURL: options.aihubmixBaseUrl,
|
||||
appCode: options.aihubmixAppCode,
|
||||
modelId: mode === "plan" ? (options as any).planModeAihubmixModelId : (options as any).actModeAihubmixModelId,
|
||||
modelInfo:
|
||||
mode === "plan" ? (options as any).planModeAihubmixModelInfo : (options as any).actModeAihubmixModelInfo,
|
||||
})
|
||||
case "minimax":
|
||||
return new MinimaxHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
minimaxApiKey: options.minimaxApiKey,
|
||||
minimaxApiLine: options.minimaxApiLine,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
})
|
||||
case "hicap":
|
||||
return new HicapHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
hicapApiKey: options.hicapApiKey,
|
||||
hicapModelId: mode === "plan" ? options.planModeHicapModelId : options.actModeHicapModelId,
|
||||
})
|
||||
case "nousResearch":
|
||||
return new NousResearchHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
nousResearchApiKey: options.nousResearchApiKey,
|
||||
apiModelId: mode === "plan" ? options.planModeNousResearchModelId : options.actModeNousResearchModelId,
|
||||
})
|
||||
case "wandb":
|
||||
return new WandbHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
wandbApiKey: options.wandbApiKey,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
})
|
||||
default:
|
||||
return new AnthropicHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
apiKey: options.apiKey,
|
||||
anthropicBaseUrl: options.anthropicBaseUrl,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function buildApiHandler(configuration: ApiConfiguration, mode: Mode): ApiHandler {
|
||||
const { planModeApiProvider, actModeApiProvider, ...options } = configuration
|
||||
|
||||
const apiProvider = mode === "plan" ? planModeApiProvider : actModeApiProvider
|
||||
|
||||
// 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 {
|
||||
const thinkingBudgetTokens = mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens
|
||||
if (thinkingBudgetTokens && thinkingBudgetTokens > 0) {
|
||||
const handler = createHandlerForProvider(apiProvider, options, mode)
|
||||
|
||||
const modelInfo = handler.getModel().info
|
||||
if (modelInfo?.maxTokens && modelInfo.maxTokens > 0 && thinkingBudgetTokens > modelInfo.maxTokens) {
|
||||
const clippedValue = modelInfo.maxTokens - 1
|
||||
if (mode === "plan") {
|
||||
options.planModeThinkingBudgetTokens = clippedValue
|
||||
} else {
|
||||
options.actModeThinkingBudgetTokens = clippedValue
|
||||
}
|
||||
} else {
|
||||
return handler // don't rebuild unless its necessary
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error("buildApiHandler error:", error)
|
||||
}
|
||||
|
||||
return createHandlerForProvider(apiProvider, options, mode)
|
||||
}
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
import { afterEach, describe, it } from "mocha"
|
||||
import sinon from "sinon"
|
||||
import "should"
|
||||
import { anthropicModels } from "@shared/api"
|
||||
import { ANTHROPIC_FAST_MODE_BETA, AnthropicHandler } from "../anthropic"
|
||||
|
||||
describe("AnthropicHandler", () => {
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
const createAsyncIterable = (data: readonly unknown[] = []) => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield* data
|
||||
},
|
||||
})
|
||||
|
||||
describe("getModel", () => {
|
||||
it("should return the fast mode model when configured", () => {
|
||||
const handler = new AnthropicHandler({
|
||||
apiKey: "test-api-key",
|
||||
apiModelId: "claude-opus-4-6:fast",
|
||||
})
|
||||
|
||||
const result = handler.getModel()
|
||||
|
||||
result.id.should.equal("claude-opus-4-6:fast")
|
||||
result.info.should.deepEqual(anthropicModels["claude-opus-4-6:fast"])
|
||||
})
|
||||
|
||||
it("should return the 1m fast mode model when configured", () => {
|
||||
const handler = new AnthropicHandler({
|
||||
apiKey: "test-api-key",
|
||||
apiModelId: "claude-opus-4-6:1m:fast",
|
||||
})
|
||||
|
||||
const result = handler.getModel()
|
||||
|
||||
result.id.should.equal("claude-opus-4-6:1m:fast")
|
||||
result.info.should.deepEqual(anthropicModels["claude-opus-4-6:1m:fast"])
|
||||
})
|
||||
|
||||
it("should return the 4.7 model when configured", () => {
|
||||
const handler = new AnthropicHandler({
|
||||
apiKey: "test-api-key",
|
||||
apiModelId: "claude-opus-4-7",
|
||||
})
|
||||
|
||||
const result = handler.getModel()
|
||||
|
||||
result.id.should.equal("claude-opus-4-7")
|
||||
result.info.should.deepEqual(anthropicModels["claude-opus-4-7"])
|
||||
})
|
||||
|
||||
it("should return the 4.7 1m model when configured", () => {
|
||||
const handler = new AnthropicHandler({
|
||||
apiKey: "test-api-key",
|
||||
apiModelId: "claude-opus-4-7:1m",
|
||||
})
|
||||
|
||||
const result = handler.getModel()
|
||||
|
||||
result.id.should.equal("claude-opus-4-7:1m")
|
||||
result.info.should.deepEqual(anthropicModels["claude-opus-4-7:1m"])
|
||||
})
|
||||
|
||||
it("should return the 4.8 model when configured", () => {
|
||||
const handler = new AnthropicHandler({
|
||||
apiKey: "test-api-key",
|
||||
apiModelId: "claude-opus-4-8",
|
||||
})
|
||||
|
||||
const result = handler.getModel()
|
||||
|
||||
result.id.should.equal("claude-opus-4-8")
|
||||
result.info.should.deepEqual(anthropicModels["claude-opus-4-8"])
|
||||
})
|
||||
|
||||
it("should return the 4.8 1m model when configured", () => {
|
||||
const handler = new AnthropicHandler({
|
||||
apiKey: "test-api-key",
|
||||
apiModelId: "claude-opus-4-8:1m",
|
||||
})
|
||||
|
||||
const result = handler.getModel()
|
||||
|
||||
result.id.should.equal("claude-opus-4-8:1m")
|
||||
result.info.should.deepEqual(anthropicModels["claude-opus-4-8:1m"])
|
||||
})
|
||||
|
||||
it("should return the Fable 5 model when configured", () => {
|
||||
const handler = new AnthropicHandler({
|
||||
apiKey: "test-api-key",
|
||||
apiModelId: "claude-fable-5",
|
||||
})
|
||||
|
||||
const result = handler.getModel()
|
||||
|
||||
result.id.should.equal("claude-fable-5")
|
||||
result.info.should.deepEqual(anthropicModels["claude-fable-5"])
|
||||
})
|
||||
|
||||
it("should return the Fable 5 1m model when configured", () => {
|
||||
const handler = new AnthropicHandler({
|
||||
apiKey: "test-api-key",
|
||||
apiModelId: "claude-fable-5:1m",
|
||||
})
|
||||
|
||||
const result = handler.getModel()
|
||||
|
||||
result.id.should.equal("claude-fable-5:1m")
|
||||
result.info.should.deepEqual(anthropicModels["claude-fable-5:1m"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("createMessage", () => {
|
||||
it("should route fast mode requests through the beta messages API", async () => {
|
||||
const handler = new AnthropicHandler({
|
||||
apiKey: "test-api-key",
|
||||
apiModelId: "claude-opus-4-6:fast",
|
||||
})
|
||||
|
||||
const standardCreate = sinon.stub().resolves(createAsyncIterable())
|
||||
const betaCreate = sinon.stub().callsFake(function (this: { _client?: object }, _params: unknown) {
|
||||
should.exist(this._client)
|
||||
return Promise.resolve(createAsyncIterable())
|
||||
})
|
||||
|
||||
sinon.stub(handler as unknown as { ensureClient: () => unknown }, "ensureClient").returns({
|
||||
messages: {
|
||||
create: standardCreate,
|
||||
},
|
||||
beta: {
|
||||
messages: {
|
||||
_client: {},
|
||||
create: betaCreate,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
for await (const _chunk of handler.createMessage("system prompt", [{ role: "user", content: "Hello" }])) {
|
||||
}
|
||||
|
||||
sinon.assert.notCalled(standardCreate)
|
||||
sinon.assert.calledOnce(betaCreate)
|
||||
sinon.assert.calledWithMatch(betaCreate, {
|
||||
model: "claude-opus-4-6",
|
||||
betas: [ANTHROPIC_FAST_MODE_BETA],
|
||||
speed: "fast",
|
||||
stream: true,
|
||||
})
|
||||
})
|
||||
|
||||
it("should include the 1m beta when routing 1m fast mode requests through the beta messages API", async () => {
|
||||
const handler = new AnthropicHandler({
|
||||
apiKey: "test-api-key",
|
||||
apiModelId: "claude-opus-4-6:1m:fast",
|
||||
})
|
||||
|
||||
const standardCreate = sinon.stub().resolves(createAsyncIterable())
|
||||
const betaCreate = sinon.stub().callsFake(function (this: { _client?: object }, _params: unknown) {
|
||||
should.exist(this._client)
|
||||
return Promise.resolve(createAsyncIterable())
|
||||
})
|
||||
|
||||
sinon.stub(handler as unknown as { ensureClient: () => unknown }, "ensureClient").returns({
|
||||
messages: {
|
||||
create: standardCreate,
|
||||
},
|
||||
beta: {
|
||||
messages: {
|
||||
_client: {},
|
||||
create: betaCreate,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
for await (const _chunk of handler.createMessage("system prompt", [{ role: "user", content: "Hello" }])) {
|
||||
}
|
||||
|
||||
sinon.assert.notCalled(standardCreate)
|
||||
sinon.assert.calledOnce(betaCreate)
|
||||
sinon.assert.calledWithMatch(betaCreate, {
|
||||
model: "claude-opus-4-6",
|
||||
betas: [ANTHROPIC_FAST_MODE_BETA, "context-1m-2025-08-07"],
|
||||
speed: "fast",
|
||||
stream: true,
|
||||
})
|
||||
})
|
||||
|
||||
it("should include the 1m beta header for Claude Opus 4.7 1m requests", async () => {
|
||||
const handler = new AnthropicHandler({
|
||||
apiKey: "test-api-key",
|
||||
apiModelId: "claude-opus-4-7:1m",
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
|
||||
const standardCreate = sinon.stub().resolves(createAsyncIterable())
|
||||
|
||||
sinon.stub(handler as unknown as { ensureClient: () => unknown }, "ensureClient").returns({
|
||||
messages: {
|
||||
create: standardCreate,
|
||||
},
|
||||
beta: {
|
||||
messages: {
|
||||
_client: {},
|
||||
create: sinon.stub().resolves(createAsyncIterable()),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
for await (const _chunk of handler.createMessage("system prompt", [{ role: "user", content: "Hello" }])) {
|
||||
}
|
||||
|
||||
sinon.assert.calledOnce(standardCreate)
|
||||
const requestBody = standardCreate.firstCall.args[0] as Record<string, any>
|
||||
const requestOptions = standardCreate.firstCall.args[1] as Record<string, any>
|
||||
requestBody.model.should.equal("claude-opus-4-7")
|
||||
requestBody.thinking.should.deepEqual({ type: "adaptive" })
|
||||
requestOptions.should.deepEqual({
|
||||
headers: {
|
||||
"anthropic-beta": "context-1m-2025-08-07",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should use adaptive thinking and output_config for Claude Opus adaptive models", async () => {
|
||||
const handler = new AnthropicHandler({
|
||||
apiKey: "test-api-key",
|
||||
apiModelId: "claude-opus-4-7",
|
||||
reasoningEffort: "xhigh",
|
||||
})
|
||||
|
||||
const standardCreate = sinon.stub().resolves(createAsyncIterable())
|
||||
|
||||
sinon.stub(handler as unknown as { ensureClient: () => unknown }, "ensureClient").returns({
|
||||
messages: {
|
||||
create: standardCreate,
|
||||
},
|
||||
beta: {
|
||||
messages: {
|
||||
_client: {},
|
||||
create: sinon.stub().resolves(createAsyncIterable()),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
for await (const _chunk of handler.createMessage("system prompt", [{ role: "user", content: "Hello" }])) {
|
||||
}
|
||||
|
||||
sinon.assert.calledOnce(standardCreate)
|
||||
const requestBody = standardCreate.firstCall.args[0] as Record<string, any>
|
||||
requestBody.should.have.property("thinking")
|
||||
requestBody.thinking.should.deepEqual({ type: "adaptive" })
|
||||
requestBody.should.have.property("output_config")
|
||||
requestBody.output_config.should.deepEqual({ effort: "xhigh" })
|
||||
should(requestBody.temperature).equal(undefined)
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,488 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, it } from "mocha"
|
||||
import sinon from "sinon"
|
||||
import "should"
|
||||
import { ClaudeCodeHandler } from "@core/api/providers/claude-code"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
|
||||
describe("ClaudeCodeHandler", () => {
|
||||
let handler: ClaudeCodeHandler
|
||||
let sandbox: sinon.SinonSandbox
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
handler = new ClaudeCodeHandler({
|
||||
claudeCodePath: "/mock/path",
|
||||
apiModelId: "claude-opus-4-1-20250805",
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore()
|
||||
})
|
||||
|
||||
describe("token counting", () => {
|
||||
it("should correctly handle token usage from assistant messages", async () => {
|
||||
// The 'input_tokens' field represents the TOTAL number of input tokens used.
|
||||
// See https://docs.anthropic.com/en/api/messages#usage-object
|
||||
|
||||
// Mock the runClaudeCode function
|
||||
const runClaudeCodeModule = await import("@/integrations/claude-code/run")
|
||||
const runClaudeCodeStub = sandbox.stub(runClaudeCodeModule, "runClaudeCode")
|
||||
|
||||
// Create a proper async generator mock for the Claude Code response
|
||||
async function* mockGenerator() {
|
||||
// First yield the system init
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
apiKeySource: "api",
|
||||
}
|
||||
|
||||
// Yield assistant message with usage data
|
||||
// Example: If base input is 70 tokens, cache read is 20, and cache creation is 10,
|
||||
// then input_tokens from Anthropic API will be 100 (70 + 20 + 10)
|
||||
yield {
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Test response",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
input_tokens: 100, // Total including cache (per Anthropic docs)
|
||||
output_tokens: 50,
|
||||
cache_read_input_tokens: 20, // Already included in input_tokens
|
||||
cache_creation_input_tokens: 10, // Already included in input_tokens
|
||||
},
|
||||
stop_reason: "end_turn",
|
||||
},
|
||||
}
|
||||
|
||||
// Yield result with cost
|
||||
yield {
|
||||
type: "result",
|
||||
result: {},
|
||||
total_cost_usd: 0.005,
|
||||
}
|
||||
}
|
||||
|
||||
runClaudeCodeStub.returns(mockGenerator() as any)
|
||||
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: ClineStorageMessage[] = [{ role: "user", content: "Hello" }]
|
||||
|
||||
const usageData: any[] = []
|
||||
|
||||
// Collect the results
|
||||
for await (const chunk of handler.createMessage(systemPrompt, messages)) {
|
||||
if (chunk.type === "usage") {
|
||||
usageData.push({
|
||||
inputTokens: chunk.inputTokens,
|
||||
outputTokens: chunk.outputTokens,
|
||||
cacheReadTokens: chunk.cacheReadTokens,
|
||||
cacheWriteTokens: chunk.cacheWriteTokens,
|
||||
totalCost: chunk.totalCost,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Verify token counting follows Anthropic API specification
|
||||
usageData.should.have.length(1)
|
||||
usageData[0].should.deepEqual({
|
||||
inputTokens: 100, // Total including cache tokens (per Anthropic API docs)
|
||||
outputTokens: 50,
|
||||
cacheReadTokens: 20, // Tracked separately for reporting
|
||||
cacheWriteTokens: 10, // Tracked separately for reporting
|
||||
totalCost: 0.005,
|
||||
})
|
||||
|
||||
// CRITICAL ASSERTION: Verify that input_tokens is NOT inflated by re-adding cache tokens
|
||||
// The bug would have caused inputTokens to be incorrectly calculated as 130 (100 + 20 + 10)
|
||||
// The fix ensures it remains 100, as per Anthropic's specification
|
||||
usageData[0].inputTokens.should.equal(100) // Correct: matches API response
|
||||
usageData[0].inputTokens.should.not.equal(130) // Would be wrong: double-counting cache tokens
|
||||
})
|
||||
|
||||
it("should handle missing usage fields with nullish coalescing", async () => {
|
||||
// Mock the runClaudeCode function
|
||||
const runClaudeCodeModule = await import("@/integrations/claude-code/run")
|
||||
const runClaudeCodeStub = sandbox.stub(runClaudeCodeModule, "runClaudeCode")
|
||||
|
||||
// Create a proper async generator mock with missing/undefined usage fields
|
||||
async function* mockGenerator() {
|
||||
yield {
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Test response",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
// cache fields are undefined/missing
|
||||
},
|
||||
stop_reason: "end_turn",
|
||||
},
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "result",
|
||||
result: {},
|
||||
total_cost_usd: 0.005,
|
||||
}
|
||||
}
|
||||
|
||||
runClaudeCodeStub.returns(mockGenerator() as any)
|
||||
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: ClineStorageMessage[] = [{ role: "user", content: "Hello" }]
|
||||
|
||||
const usageData: any[] = []
|
||||
|
||||
// Collect the results
|
||||
for await (const chunk of handler.createMessage(systemPrompt, messages)) {
|
||||
if (chunk.type === "usage") {
|
||||
usageData.push({
|
||||
inputTokens: chunk.inputTokens,
|
||||
outputTokens: chunk.outputTokens,
|
||||
cacheReadTokens: chunk.cacheReadTokens,
|
||||
cacheWriteTokens: chunk.cacheWriteTokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Verify that undefined cache tokens default to 0
|
||||
usageData.should.have.length(1)
|
||||
usageData[0].should.deepEqual({
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheReadTokens: 0, // Should default to 0
|
||||
cacheWriteTokens: 0, // Should default to 0
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle completely missing usage object", async () => {
|
||||
// Mock the runClaudeCode function
|
||||
const runClaudeCodeModule = await import("@/integrations/claude-code/run")
|
||||
const runClaudeCodeStub = sandbox.stub(runClaudeCodeModule, "runClaudeCode")
|
||||
|
||||
// Create a proper async generator mock with missing usage object
|
||||
async function* mockGenerator() {
|
||||
yield {
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Test response",
|
||||
},
|
||||
],
|
||||
// usage is undefined
|
||||
usage: undefined,
|
||||
stop_reason: "end_turn",
|
||||
},
|
||||
}
|
||||
|
||||
// Need to yield a result chunk to trigger usage data emission
|
||||
yield {
|
||||
type: "result",
|
||||
result: {},
|
||||
total_cost_usd: 0,
|
||||
}
|
||||
}
|
||||
|
||||
runClaudeCodeStub.returns(mockGenerator() as any)
|
||||
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: ClineStorageMessage[] = [{ role: "user", content: "Hello" }]
|
||||
|
||||
const usageData: any[] = []
|
||||
|
||||
// Collect the results
|
||||
for await (const chunk of handler.createMessage(systemPrompt, messages)) {
|
||||
if (chunk.type === "usage") {
|
||||
usageData.push({
|
||||
inputTokens: chunk.inputTokens,
|
||||
outputTokens: chunk.outputTokens,
|
||||
cacheReadTokens: chunk.cacheReadTokens,
|
||||
cacheWriteTokens: chunk.cacheWriteTokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// All token counts should default to 0 when usage is undefined
|
||||
usageData.should.have.length(1)
|
||||
usageData[0].should.deepEqual({
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should not crash when assistant message has empty content array", async () => {
|
||||
const runClaudeCodeModule = await import("@/integrations/claude-code/run")
|
||||
const runClaudeCodeStub = sandbox.stub(runClaudeCodeModule, "runClaudeCode")
|
||||
|
||||
async function* mockGenerator() {
|
||||
yield {
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [], // empty content — triggered TypeError in older code
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 0,
|
||||
},
|
||||
stop_reason: "end_turn",
|
||||
},
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "result",
|
||||
result: {},
|
||||
total_cost_usd: 0,
|
||||
}
|
||||
}
|
||||
|
||||
runClaudeCodeStub.returns(mockGenerator() as any)
|
||||
|
||||
const chunks: any[] = []
|
||||
// Should not throw
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const usageChunk = chunks.find((c) => c.type === "usage")
|
||||
usageChunk.should.be.ok()
|
||||
usageChunk.inputTokens.should.equal(10)
|
||||
})
|
||||
|
||||
it("should throw when result has is_error=true (e.g. rate limit with no assistant message)", async () => {
|
||||
const runClaudeCodeModule = await import("@/integrations/claude-code/run")
|
||||
const runClaudeCodeStub = sandbox.stub(runClaudeCodeModule, "runClaudeCode")
|
||||
|
||||
async function* mockGenerator() {
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
apiKeySource: "none",
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "rate_limit_event",
|
||||
message: "Rate limit hit",
|
||||
retryAfterSeconds: 30,
|
||||
}
|
||||
|
||||
// No assistant message — CLI hit rate limit and gave up
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "error",
|
||||
is_error: true,
|
||||
result: "Rate limit exceeded",
|
||||
total_cost_usd: 0,
|
||||
duration_ms: 1000,
|
||||
duration_api_ms: 500,
|
||||
num_turns: 0,
|
||||
session_id: "test",
|
||||
}
|
||||
}
|
||||
|
||||
runClaudeCodeStub.returns(mockGenerator() as any)
|
||||
|
||||
let thrownError: Error | undefined
|
||||
try {
|
||||
for await (const _ of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
// consume
|
||||
}
|
||||
} catch (err) {
|
||||
thrownError = err as Error
|
||||
}
|
||||
|
||||
thrownError!.message.should.containEql("Rate limit exceeded")
|
||||
})
|
||||
|
||||
it("should ignore rate_limit_event system messages without throwing", async () => {
|
||||
const runClaudeCodeModule = await import("@/integrations/claude-code/run")
|
||||
const runClaudeCodeStub = sandbox.stub(runClaudeCodeModule, "runClaudeCode")
|
||||
|
||||
async function* mockGenerator() {
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
apiKeySource: "none",
|
||||
}
|
||||
|
||||
// Newer Claude Code CLI emits this during rate limiting
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "rate_limit_event",
|
||||
message: "Rate limit hit, retrying...",
|
||||
retryAfterSeconds: 30,
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [{ type: "text", text: "Response after retry" }],
|
||||
usage: { input_tokens: 20, output_tokens: 10 },
|
||||
stop_reason: "end_turn",
|
||||
},
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "result",
|
||||
result: {},
|
||||
total_cost_usd: 0,
|
||||
}
|
||||
}
|
||||
|
||||
runClaudeCodeStub.returns(mockGenerator() as any)
|
||||
|
||||
const textChunks: string[] = []
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
if (chunk.type === "text") textChunks.push(chunk.text)
|
||||
}
|
||||
|
||||
textChunks.should.deepEqual(["Response after retry"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("getModel", () => {
|
||||
it("should return the correct model when specified", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "claude-sonnet-4-5-20250929",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("claude-sonnet-4-5-20250929")
|
||||
})
|
||||
|
||||
it("should support Opus 4.6 1m model id", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "claude-opus-4-6[1m]",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("claude-opus-4-6[1m]")
|
||||
model.info.contextWindow.should.equal(1_000_000)
|
||||
})
|
||||
|
||||
it("should support Opus 4.7 model id", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "claude-opus-4-7",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("claude-opus-4-7")
|
||||
model.info.contextWindow.should.equal(200_000)
|
||||
})
|
||||
|
||||
it("should support Opus 4.7 1m model id", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "claude-opus-4-7[1m]",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("claude-opus-4-7[1m]")
|
||||
model.info.contextWindow.should.equal(1_000_000)
|
||||
})
|
||||
|
||||
it("should support Opus 4.8 model id", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "claude-opus-4-8",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("claude-opus-4-8")
|
||||
model.info.contextWindow.should.equal(200_000)
|
||||
})
|
||||
|
||||
it("should support Opus 4.8 1m model id", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "claude-opus-4-8[1m]",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("claude-opus-4-8[1m]")
|
||||
model.info.contextWindow.should.equal(1_000_000)
|
||||
})
|
||||
|
||||
it("should support Fable 5 model id", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "claude-fable-5",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("claude-fable-5")
|
||||
model.info.contextWindow.should.equal(200_000)
|
||||
})
|
||||
|
||||
it("should support Fable 5 1m model id", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "claude-fable-5[1m]",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("claude-fable-5[1m]")
|
||||
model.info.contextWindow.should.equal(1_000_000)
|
||||
})
|
||||
|
||||
it("should support Opus 1m alias model id", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "opus[1m]",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("opus[1m]")
|
||||
model.info.contextWindow.should.equal(1_000_000)
|
||||
})
|
||||
|
||||
it("should support Sonnet 1m alias model id", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "sonnet[1m]",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("sonnet[1m]")
|
||||
model.info.contextWindow.should.equal(1_000_000)
|
||||
})
|
||||
|
||||
it("should support Sonnet 4.5 1m model id", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "claude-sonnet-4-5-20250929[1m]",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("claude-sonnet-4-5-20250929[1m]")
|
||||
model.info.contextWindow.should.equal(1_000_000)
|
||||
})
|
||||
|
||||
it("should support Sonnet 4.6 1m model id", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "claude-sonnet-4-6[1m]",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("claude-sonnet-4-6[1m]")
|
||||
model.info.contextWindow.should.equal(1_000_000)
|
||||
})
|
||||
|
||||
it("should return default model when not specified", () => {
|
||||
const handler = new ClaudeCodeHandler({})
|
||||
|
||||
const model = handler.getModel()
|
||||
// The default model should be set
|
||||
model.id.should.be.type("string")
|
||||
model.info.should.be.type("object")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,197 +0,0 @@
|
||||
import "should"
|
||||
import { openRouterDefaultModelInfo } from "@shared/api"
|
||||
import sinon from "sinon"
|
||||
import { ClineAccountService } from "@/services/account/ClineAccountService"
|
||||
import { AuthService } from "@/services/auth/AuthService"
|
||||
import { ClineError, ClineErrorType } from "@/services/error/ClineError"
|
||||
import { ClineHandler } from "../cline"
|
||||
|
||||
describe("ClineHandler", () => {
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
const createAsyncIterable = (data: any[] = []) => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield* data
|
||||
},
|
||||
})
|
||||
|
||||
const createHandler = (options: ConstructorParameters<typeof ClineHandler>[0]) => {
|
||||
sinon.stub(ClineAccountService, "getInstance").returns({} as any)
|
||||
sinon.stub(AuthService, "getInstance").returns({} as any)
|
||||
return new ClineHandler(options)
|
||||
}
|
||||
|
||||
it("should handle usage-only chunks when delta is missing", async () => {
|
||||
const handler = createHandler({})
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
choices: [{}],
|
||||
usage: {
|
||||
prompt_tokens: 17,
|
||||
completion_tokens: 9,
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").resolves(fakeClient as any)
|
||||
sinon.stub(handler, "getModel").returns({
|
||||
id: "openai/gpt-4o-mini",
|
||||
info: openRouterDefaultModelInfo,
|
||||
})
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
chunks.should.deepEqual([
|
||||
{
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
inputTokens: 17,
|
||||
outputTokens: 9,
|
||||
totalCost: 0,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should read Anthropic-style cache creation and read tokens from usage chunks", async () => {
|
||||
const handler = createHandler({})
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
choices: [{}],
|
||||
usage: {
|
||||
prompt_tokens: 1000,
|
||||
completion_tokens: 200,
|
||||
prompt_tokens_details: {
|
||||
cached_tokens: 500,
|
||||
},
|
||||
cache_creation_input_tokens: 300,
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").resolves(fakeClient as any)
|
||||
sinon.stub(handler, "getModel").returns({
|
||||
id: "anthropic/claude-sonnet-4.6",
|
||||
info: openRouterDefaultModelInfo,
|
||||
})
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
chunks.should.deepEqual([
|
||||
{
|
||||
type: "usage",
|
||||
cacheWriteTokens: 300,
|
||||
cacheReadTokens: 500,
|
||||
inputTokens: 200,
|
||||
outputTokens: 200,
|
||||
totalCost: 0,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should forward enableParallelToolCalling to OpenRouter payload", async () => {
|
||||
const handler = createHandler({ enableParallelToolCalling: true })
|
||||
const createStub = sinon.stub().resolves(createAsyncIterable([]))
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: createStub,
|
||||
},
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").resolves(fakeClient as any)
|
||||
sinon.stub(handler, "getModel").returns({
|
||||
id: "openai/gpt-4o-mini",
|
||||
info: openRouterDefaultModelInfo,
|
||||
})
|
||||
|
||||
const tools = [
|
||||
{ type: "function", function: { name: "read_file", description: "", parameters: { type: "object" } } },
|
||||
] as any
|
||||
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "hi" }], tools)) {
|
||||
// drain stream
|
||||
}
|
||||
|
||||
const payload = createStub.firstCall.args[0]
|
||||
payload.parallel_tool_calls.should.equal(true)
|
||||
})
|
||||
|
||||
it("should send cache_control for qwen3.7-max without changing the selected Cline model id", async () => {
|
||||
const handler = createHandler({
|
||||
openRouterModelId: "qwen/qwen3.7-max",
|
||||
openRouterModelInfo: openRouterDefaultModelInfo,
|
||||
})
|
||||
const createStub = sinon.stub().resolves(createAsyncIterable([]))
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: createStub,
|
||||
},
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").resolves(fakeClient as any)
|
||||
sinon.stub(handler as any, "getFreeModelIdSet").resolves(new Set())
|
||||
|
||||
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
// drain stream
|
||||
}
|
||||
|
||||
handler.getModel().id.should.equal("qwen/qwen3.7-max")
|
||||
const payload = createStub.firstCall.args[0]
|
||||
payload.model.should.equal("qwen/qwen3.7-max")
|
||||
payload.messages[0].content[0].cache_control.should.deepEqual({ type: "ephemeral" })
|
||||
payload.messages[1].content[0].cache_control.should.deepEqual({ type: "ephemeral" })
|
||||
})
|
||||
|
||||
it("propagates a pre-stream 403 entitlement error so it classifies as Entitlement", async () => {
|
||||
const handler = createHandler({})
|
||||
// A 403 from ValidateModelEntitlement rejects completions.create() before streaming,
|
||||
// matching the OpenAI SDK APIError shape (status + code + error body).
|
||||
const apiError = Object.assign(new Error("403 the user is not subscribed to required model plan"), {
|
||||
status: 403,
|
||||
code: "ENTITLEMENT_ERROR",
|
||||
error: {
|
||||
code: "ENTITLEMENT_ERROR",
|
||||
message: "Error 403: the user is not subscribed to required model plan",
|
||||
},
|
||||
})
|
||||
const fakeClient = { chat: { completions: { create: sinon.stub().rejects(apiError) } } }
|
||||
sinon.stub(handler as any, "ensureClient").resolves(fakeClient as any)
|
||||
sinon.stub(handler as any, "getFreeModelIdSet").resolves(new Set())
|
||||
sinon.stub(handler, "getModel").returns({ id: "cline-pass/glm-5.1", info: openRouterDefaultModelInfo })
|
||||
|
||||
let thrown: unknown
|
||||
try {
|
||||
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
// drain
|
||||
}
|
||||
} catch (e) {
|
||||
thrown = e
|
||||
}
|
||||
|
||||
const clineError = ClineError.transform(thrown, "cline-pass/glm-5.1", "cline-pass")
|
||||
clineError.isErrorType(ClineErrorType.Entitlement).should.be.true()
|
||||
})
|
||||
})
|
||||
@@ -1,97 +0,0 @@
|
||||
import "should"
|
||||
import sinon from "sinon"
|
||||
import { FireworksHandler } from "../fireworks"
|
||||
|
||||
describe("FireworksHandler", () => {
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
const createAsyncIterable = (data: any[] = []) => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield* data
|
||||
},
|
||||
})
|
||||
|
||||
it("should handle usage-only chunks when delta is missing", async () => {
|
||||
const handler = new FireworksHandler({
|
||||
fireworksApiKey: "test-api-key",
|
||||
fireworksModelId: "accounts/fireworks/models/llama-v3p1-8b-instruct",
|
||||
})
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
choices: [{}],
|
||||
usage: {
|
||||
prompt_tokens: 19,
|
||||
completion_tokens: 4,
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
chunks.should.deepEqual([
|
||||
{
|
||||
type: "usage",
|
||||
inputTokens: 19,
|
||||
outputTokens: 4,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should read cache hits from prompt_tokens_details when hit tokens are not present", async () => {
|
||||
const handler = new FireworksHandler({
|
||||
fireworksApiKey: "test-api-key",
|
||||
fireworksModelId: "accounts/fireworks/models/llama-v3p1-8b-instruct",
|
||||
})
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
choices: [{}],
|
||||
usage: {
|
||||
prompt_tokens: 60,
|
||||
completion_tokens: 12,
|
||||
prompt_tokens_details: { cached_tokens: 20 },
|
||||
prompt_cache_miss_tokens: 40,
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
chunks.should.deepEqual([
|
||||
{
|
||||
type: "usage",
|
||||
inputTokens: 60,
|
||||
outputTokens: 12,
|
||||
cacheReadTokens: 20,
|
||||
cacheWriteTokens: 40,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,235 +0,0 @@
|
||||
import "should"
|
||||
import sinon from "sinon"
|
||||
import { GeminiHandler } from "../gemini"
|
||||
|
||||
describe("GeminiHandler", () => {
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
const createAsyncIterable = (data: any[] = []) => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield* data
|
||||
},
|
||||
})
|
||||
|
||||
it("caps maxOutputTokens to 8192 for Flash models", async () => {
|
||||
const handler = new GeminiHandler({
|
||||
geminiApiKey: "test-api-key",
|
||||
apiModelId: "gemini-2.5-flash",
|
||||
})
|
||||
|
||||
const generateContentStream = sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
responseId: "resp-1",
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 20,
|
||||
cachedContentTokenCount: 0,
|
||||
thoughtsTokenCount: 0,
|
||||
},
|
||||
},
|
||||
]),
|
||||
)
|
||||
sinon.stub(handler as any, "ensureClient").returns({
|
||||
models: { generateContentStream },
|
||||
} as any)
|
||||
|
||||
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "hi" }] as any)) {
|
||||
// Consume stream to trigger request execution.
|
||||
}
|
||||
|
||||
const requestArgs = generateContentStream.firstCall.args[0] as Record<string, any>
|
||||
requestArgs.config.should.have.property("maxOutputTokens", 8_192)
|
||||
})
|
||||
|
||||
it("supports Gemini 3.5 Flash model metadata", async () => {
|
||||
const handler = new GeminiHandler({
|
||||
geminiApiKey: "test-api-key",
|
||||
apiModelId: "gemini-3.5-flash",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("gemini-3.5-flash")
|
||||
model.info.contextWindow!.should.equal(1_048_576)
|
||||
model.info.inputPrice!.should.equal(1.5)
|
||||
model.info.outputPrice!.should.equal(9)
|
||||
model.info.cacheReadsPrice!.should.equal(0.15)
|
||||
model.info.supportsReasoning!.should.equal(true)
|
||||
|
||||
const generateContentStream = sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
responseId: "resp-35",
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 20,
|
||||
cachedContentTokenCount: 0,
|
||||
thoughtsTokenCount: 0,
|
||||
},
|
||||
},
|
||||
]),
|
||||
)
|
||||
sinon.stub(handler as any, "ensureClient").returns({
|
||||
models: { generateContentStream },
|
||||
} as any)
|
||||
|
||||
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "hi" }] as any)) {
|
||||
// Consume stream to trigger request execution.
|
||||
}
|
||||
|
||||
const requestArgs = generateContentStream.firstCall.args[0] as Record<string, any>
|
||||
requestArgs.model.should.equal("gemini-3.5-flash")
|
||||
requestArgs.config.should.have.property("maxOutputTokens", 8_192)
|
||||
requestArgs.config.thinkingConfig.should.deepEqual({
|
||||
thinkingBudget: undefined,
|
||||
thinkingLevel: "LOW",
|
||||
includeThoughts: true,
|
||||
})
|
||||
})
|
||||
|
||||
it("does not set maxOutputTokens for non-Flash models", async () => {
|
||||
const handler = new GeminiHandler({
|
||||
geminiApiKey: "test-api-key",
|
||||
apiModelId: "gemini-2.5-pro",
|
||||
})
|
||||
|
||||
const generateContentStream = sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
responseId: "resp-2",
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 20,
|
||||
cachedContentTokenCount: 0,
|
||||
thoughtsTokenCount: 0,
|
||||
},
|
||||
},
|
||||
]),
|
||||
)
|
||||
sinon.stub(handler as any, "ensureClient").returns({
|
||||
models: { generateContentStream },
|
||||
} as any)
|
||||
|
||||
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "hi" }] as any)) {
|
||||
// Consume stream to trigger request execution.
|
||||
}
|
||||
|
||||
const requestArgs = generateContentStream.firstCall.args[0] as Record<string, any>
|
||||
requestArgs.config.should.not.have.property("maxOutputTokens")
|
||||
})
|
||||
|
||||
it("should emit unique tool call IDs when multiple function calls share one responseId", async () => {
|
||||
const handler = new GeminiHandler({
|
||||
geminiApiKey: "test-api-key",
|
||||
})
|
||||
|
||||
const fakeClient = {
|
||||
models: {
|
||||
generateContentStream: sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
responseId: "resp_1",
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: "read_file",
|
||||
args: { path: ".nvmrc" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
responseId: "resp_1",
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: "read_file",
|
||||
args: { path: ".gitattributes" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
|
||||
|
||||
const tools = [{ name: "read_file", description: "read file", parameters: { type: "OBJECT" } }] as any
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }], tools)) {
|
||||
if (chunk.type === "tool_calls") {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
chunks.should.have.length(2)
|
||||
chunks[0].tool_call.function.id.should.equal("resp_1-tool-0")
|
||||
chunks[1].tool_call.function.id.should.equal("resp_1-tool-1")
|
||||
chunks[0].tool_call.call_id.should.equal(chunks[0].tool_call.function.id)
|
||||
chunks[1].tool_call.call_id.should.equal(chunks[1].tool_call.function.id)
|
||||
JSON.parse(chunks[0].tool_call.function.arguments).path.should.equal(".nvmrc")
|
||||
JSON.parse(chunks[1].tool_call.function.arguments).path.should.equal(".gitattributes")
|
||||
})
|
||||
|
||||
it("should preserve Gemini-provided functionCall.id when present", async () => {
|
||||
const handler = new GeminiHandler({
|
||||
geminiApiKey: "test-api-key",
|
||||
})
|
||||
|
||||
const fakeClient = {
|
||||
models: {
|
||||
generateContentStream: sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
responseId: "resp_2",
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
id: "call_alpha",
|
||||
name: "read_file",
|
||||
args: { path: ".nvmrc" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
|
||||
|
||||
const tools = [{ name: "read_file", description: "read file", parameters: { type: "OBJECT" } }] as any
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }], tools)) {
|
||||
if (chunk.type === "tool_calls") {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
chunks.should.have.length(1)
|
||||
chunks[0].tool_call.function.id.should.equal("call_alpha")
|
||||
chunks[0].tool_call.call_id.should.equal("call_alpha")
|
||||
JSON.parse(chunks[0].tool_call.function.arguments).path.should.equal(".nvmrc")
|
||||
})
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
import "should";
|
||||
import {
|
||||
huggingFaceDefaultModelId,
|
||||
huggingFaceModels,
|
||||
} from "../../../../shared/api";
|
||||
import { HuggingFaceHandler } from "../huggingface";
|
||||
|
||||
describe("HuggingFaceHandler", () => {
|
||||
it("uses dynamic Hugging Face model info for models outside the static list", () => {
|
||||
const modelInfo = {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Available on providers: test-provider",
|
||||
};
|
||||
|
||||
const handler = new HuggingFaceHandler({
|
||||
huggingFaceApiKey: "test-api-key",
|
||||
huggingFaceModelId: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
|
||||
huggingFaceModelInfo: modelInfo,
|
||||
});
|
||||
|
||||
handler.getModel().should.deepEqual({
|
||||
id: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
|
||||
info: modelInfo,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves unknown model IDs when model info is unavailable", () => {
|
||||
const handler = new HuggingFaceHandler({
|
||||
huggingFaceApiKey: "test-api-key",
|
||||
huggingFaceModelId: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
|
||||
});
|
||||
|
||||
handler.getModel().should.deepEqual({
|
||||
id: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
|
||||
info: huggingFaceModels[huggingFaceDefaultModelId],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,326 +0,0 @@
|
||||
import { LiteLlmHandler, type LiteLlmModelInfoResponse } from "@core/api/providers/litellm"
|
||||
import { convertToOpenAiMessages } from "@core/api/transform/openai-format"
|
||||
import { liteLlmModelInfoSaneDefaults } from "@shared/api" // used in getModel tests
|
||||
import { expect } from "chai"
|
||||
import sinon from "sinon"
|
||||
import { StateManager } from "@/core/storage/StateManager" // used in getModel tests
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { mockFetchForTesting } from "@/shared/net"
|
||||
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: sinon.stub(),
|
||||
},
|
||||
},
|
||||
baseURL: "https://fake.example",
|
||||
}
|
||||
|
||||
describe("LiteLlmHandler", () => {
|
||||
const mockFetch = sinon.stub()
|
||||
let doneMockingFetch: (value: any) => void = () => {}
|
||||
|
||||
const mockModelFetch = (modelInfo: LiteLlmModelInfoResponse["data"][number]) => {
|
||||
mockFetch.resolves({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [modelInfo],
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
let handler: LiteLlmHandler
|
||||
|
||||
const mockHandlerChat = () => {
|
||||
sinon.stub(handler, "ensureClient" as any).returns(fakeClient)
|
||||
}
|
||||
|
||||
const initializeHandler = (model: string) => {
|
||||
handler = new LiteLlmHandler({
|
||||
liteLlmApiKey: "test-api-key",
|
||||
liteLlmBaseUrl: "http://localhost:4000",
|
||||
liteLlmUsePromptCache: true,
|
||||
liteLlmModelId: model,
|
||||
})
|
||||
|
||||
mockHandlerChat()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fakeClient.chat.completions.create.resetHistory()
|
||||
|
||||
mockFetchForTesting(mockFetch, () => {
|
||||
return new Promise((resolve) => {
|
||||
doneMockingFetch = resolve
|
||||
})
|
||||
})
|
||||
|
||||
// Configure the stub to return a stream that closes immediately with usage data
|
||||
fakeClient.chat.completions.create.resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
choices: [{ delta: { content: "test response" } }],
|
||||
},
|
||||
{
|
||||
choices: [{}],
|
||||
usage: {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 50,
|
||||
cache_creation_input_tokens: 20,
|
||||
cache_read_input_tokens: 10,
|
||||
},
|
||||
},
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.reset()
|
||||
doneMockingFetch(void 0)
|
||||
})
|
||||
|
||||
const createAsyncIterable = (data: any[] = []) => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield* data
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("prompt cache", () => {
|
||||
const setModelData = (model: string, supportsPromptCaching: boolean) => {
|
||||
mockModelFetch({
|
||||
model_name: model,
|
||||
litellm_params: {
|
||||
model,
|
||||
},
|
||||
model_info: {
|
||||
supports_prompt_caching: supportsPromptCaching,
|
||||
input_cost_per_token: 0.01,
|
||||
output_cost_per_token: 0.02,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe("when the model doesn't support prompt caching", () => {
|
||||
const model = "openai/gpt-5"
|
||||
|
||||
beforeEach(() => {
|
||||
initializeHandler(model)
|
||||
setModelData(model, false)
|
||||
})
|
||||
|
||||
it("sends the system prompt and messages with the openai format", async () => {
|
||||
const systemPrompt = "Test System Prompt"
|
||||
const messages: ClineStorageMessage[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: "first message",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: "first response",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "test",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "second message",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
for await (const _ of handler.createMessage(systemPrompt, messages)) {
|
||||
}
|
||||
|
||||
sinon.assert.calledOnce(fakeClient.chat.completions.create)
|
||||
|
||||
const callArgs = fakeClient.chat.completions.create.getCall(0).args[0]
|
||||
|
||||
const systemPromptMessage = callArgs.messages.shift()
|
||||
expect(systemPromptMessage).to.deep.equal({
|
||||
role: "system",
|
||||
content: systemPrompt,
|
||||
})
|
||||
|
||||
expect(callArgs.messages).to.deep.equal(convertToOpenAiMessages(messages))
|
||||
})
|
||||
})
|
||||
|
||||
describe("when the model supports prompt caching", () => {
|
||||
const model = "anthropic/claude-sonnet-4-20250514"
|
||||
|
||||
beforeEach(() => {
|
||||
initializeHandler(model)
|
||||
|
||||
setModelData(model, true)
|
||||
})
|
||||
|
||||
it("inserts the cache control in the system prompt and the last two user messages", async () => {
|
||||
const systemPrompt = "Test System Prompt"
|
||||
const messages: ClineStorageMessage[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: "first message",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: "first response",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "test",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "second message",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
for await (const _ of handler.createMessage(systemPrompt, messages)) {
|
||||
}
|
||||
|
||||
sinon.assert.calledOnce(fakeClient.chat.completions.create)
|
||||
|
||||
const callArgs = fakeClient.chat.completions.create.getCall(0).args[0]
|
||||
|
||||
expect(callArgs.messages[0]).to.deep.equal({
|
||||
role: "system",
|
||||
content: [
|
||||
{
|
||||
text: systemPrompt,
|
||||
type: "text",
|
||||
cache_control: {
|
||||
type: "ephemeral",
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const sentMessages = callArgs.messages
|
||||
expect(sentMessages.length).to.equal(4)
|
||||
|
||||
const firstUserMessage = sentMessages[1]
|
||||
|
||||
expect(firstUserMessage).to.deep.equal({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "first message",
|
||||
cache_control: {
|
||||
type: "ephemeral",
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const lastUserMessage = sentMessages[3]
|
||||
expect(lastUserMessage.content[0]).to.deep.equal({
|
||||
type: "text",
|
||||
text: "test",
|
||||
})
|
||||
|
||||
const lastContentBlock = lastUserMessage.content[lastUserMessage.content.length - 1]
|
||||
expect(lastContentBlock).to.deep.equal({
|
||||
type: "text",
|
||||
text: "second message",
|
||||
cache_control: {
|
||||
type: "ephemeral",
|
||||
},
|
||||
})
|
||||
|
||||
expect(callArgs.model).to.be.a("string")
|
||||
expect(callArgs.stream).to.equal(true)
|
||||
expect(callArgs.stream_options).to.deep.equal({ include_usage: true })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("getModel", () => {
|
||||
let stateManagerStub: sinon.SinonStub
|
||||
|
||||
beforeEach(() => {
|
||||
stateManagerStub = sinon.stub(StateManager, "get").returns({
|
||||
getModelInfo: () => null,
|
||||
} as any)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
stateManagerStub.restore()
|
||||
})
|
||||
|
||||
it("returns sane defaults when no liteLlmModelInfo option is provided", () => {
|
||||
const h = new LiteLlmHandler({
|
||||
liteLlmApiKey: "test",
|
||||
liteLlmModelId: "some-model",
|
||||
})
|
||||
const model = h.getModel()
|
||||
expect(model.id).to.equal("some-model")
|
||||
expect(model.info.contextWindow).to.equal(liteLlmModelInfoSaneDefaults.contextWindow)
|
||||
})
|
||||
|
||||
it("returns user-configured model info when liteLlmModelInfo is provided and no cache exists", () => {
|
||||
const h = new LiteLlmHandler({
|
||||
liteLlmApiKey: "test",
|
||||
liteLlmModelId: "claude-sonnet-4-6",
|
||||
liteLlmModelInfo: {
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 8192,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 3,
|
||||
outputPrice: 15,
|
||||
cacheWritesPrice: 3.75,
|
||||
cacheReadsPrice: 0.3,
|
||||
},
|
||||
})
|
||||
const model = h.getModel()
|
||||
expect(model.id).to.equal("claude-sonnet-4-6")
|
||||
expect(model.info.contextWindow).to.equal(1_000_000)
|
||||
})
|
||||
|
||||
it("prefers StateManager cached model info over user-configured liteLlmModelInfo", () => {
|
||||
const cachedInfo = {
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 4096,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
}
|
||||
stateManagerStub.returns({
|
||||
getModelInfo: () => cachedInfo,
|
||||
} as any)
|
||||
|
||||
const h = new LiteLlmHandler({
|
||||
liteLlmApiKey: "test",
|
||||
liteLlmModelId: "claude-sonnet-4-6",
|
||||
liteLlmModelInfo: {
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 8192,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 3,
|
||||
outputPrice: 15,
|
||||
cacheWritesPrice: 3.75,
|
||||
cacheReadsPrice: 0.3,
|
||||
},
|
||||
})
|
||||
const model = h.getModel()
|
||||
expect(model.info.contextWindow).to.equal(200_000)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,53 +0,0 @@
|
||||
import "should"
|
||||
import { moonshotModels } from "@shared/api"
|
||||
import type { ClineStorageMessage } from "@shared/messages/content"
|
||||
import sinon from "sinon"
|
||||
import { MoonshotHandler } from "../moonshot"
|
||||
|
||||
interface MoonshotRequestPayload {
|
||||
model: string
|
||||
temperature: number
|
||||
max_tokens: number
|
||||
}
|
||||
|
||||
describe("MoonshotHandler", () => {
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
const createAsyncIterable = (data: unknown[] = []): AsyncIterable<unknown> => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield* data
|
||||
},
|
||||
})
|
||||
|
||||
it("supports kimi-k2.6 model metadata", async () => {
|
||||
const handler = new MoonshotHandler({
|
||||
moonshotApiKey: "test-api-key",
|
||||
apiModelId: "kimi-k2.6",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("kimi-k2.6")
|
||||
model.info.should.deepEqual(moonshotModels["kimi-k2.6"])
|
||||
|
||||
const createStub = sinon.stub().resolves(createAsyncIterable([]))
|
||||
sinon.stub(handler as unknown as { ensureClient: () => unknown }, "ensureClient").returns({
|
||||
chat: {
|
||||
completions: {
|
||||
create: createStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const messages: ClineStorageMessage[] = [{ role: "user", content: "hi" }]
|
||||
for await (const _chunk of handler.createMessage("system", messages)) {
|
||||
// Consume stream to trigger request execution.
|
||||
}
|
||||
|
||||
const payload = createStub.firstCall.args[0] as MoonshotRequestPayload
|
||||
payload.model.should.equal("kimi-k2.6")
|
||||
payload.temperature.should.equal(moonshotModels["kimi-k2.6"].temperature)
|
||||
payload.max_tokens.should.equal(moonshotModels["kimi-k2.6"].maxTokens)
|
||||
})
|
||||
})
|
||||
@@ -1,93 +0,0 @@
|
||||
import { expect } from "chai"
|
||||
import { afterEach, describe, it } from "mocha"
|
||||
import sinon from "sinon"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { ApiFormat } from "@/shared/proto/index.cline"
|
||||
import { OcaHandler } from "../oca"
|
||||
|
||||
const messages: ClineStorageMessage[] = [{ role: "user", content: "Hello" }]
|
||||
|
||||
async function collectChunks(stream: AsyncGenerator<any>) {
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
describe("OcaHandler.createMessage", () => {
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
it("routes OPENAI_RESPONSES models to createMessageResponsesApi", async () => {
|
||||
const handler = new OcaHandler({
|
||||
ocaModelInfo: { apiFormat: ApiFormat.OPENAI_RESPONSES } as any,
|
||||
})
|
||||
|
||||
const chatStub = sinon.stub(handler as any, "createMessageChatApi").callsFake(async function* () {
|
||||
yield { type: "text", text: "chat" }
|
||||
})
|
||||
const responsesStub = sinon.stub(handler as any, "createMessageResponsesApi").callsFake(async function* () {
|
||||
yield { type: "text", text: "responses" }
|
||||
})
|
||||
const messagesStub = sinon.stub(handler as any, "createMessageMessagesApi").callsFake(async function* () {
|
||||
yield { type: "text", text: "messages" }
|
||||
})
|
||||
|
||||
const chunks = await collectChunks(handler.createMessage("system", messages))
|
||||
|
||||
expect(chunks).to.deep.equal([{ type: "text", text: "responses" }])
|
||||
sinon.assert.notCalled(chatStub)
|
||||
sinon.assert.calledOnce(responsesStub)
|
||||
sinon.assert.notCalled(messagesStub)
|
||||
})
|
||||
|
||||
it("routes ANTHROPIC_CHAT models to createMessageMessagesApi", async () => {
|
||||
const handler = new OcaHandler({
|
||||
ocaModelInfo: { apiFormat: ApiFormat.ANTHROPIC_CHAT } as any,
|
||||
})
|
||||
|
||||
const chatStub = sinon.stub(handler as any, "createMessageChatApi").callsFake(async function* () {
|
||||
yield { type: "text", text: "chat" }
|
||||
})
|
||||
const responsesStub = sinon.stub(handler as any, "createMessageResponsesApi").callsFake(async function* () {
|
||||
yield { type: "text", text: "responses" }
|
||||
})
|
||||
const messagesStub = sinon.stub(handler as any, "createMessageMessagesApi").callsFake(async function* () {
|
||||
yield { type: "text", text: "messages" }
|
||||
})
|
||||
|
||||
const chunks = await collectChunks(handler.createMessage("system", messages))
|
||||
|
||||
expect(chunks).to.deep.equal([{ type: "text", text: "messages" }])
|
||||
sinon.assert.notCalled(chatStub)
|
||||
sinon.assert.notCalled(responsesStub)
|
||||
sinon.assert.calledOnce(messagesStub)
|
||||
})
|
||||
|
||||
it("defaults to createMessageChatApi for OPENAI_CHAT and undefined apiFormat", async () => {
|
||||
for (const apiFormat of [ApiFormat.OPENAI_CHAT, undefined]) {
|
||||
const handler = new OcaHandler({
|
||||
ocaModelInfo: { apiFormat } as any,
|
||||
})
|
||||
|
||||
const chatStub = sinon.stub(handler as any, "createMessageChatApi").callsFake(async function* () {
|
||||
yield { type: "text", text: "chat" }
|
||||
})
|
||||
const responsesStub = sinon.stub(handler as any, "createMessageResponsesApi").callsFake(async function* () {
|
||||
yield { type: "text", text: "responses" }
|
||||
})
|
||||
const messagesStub = sinon.stub(handler as any, "createMessageMessagesApi").callsFake(async function* () {
|
||||
yield { type: "text", text: "messages" }
|
||||
})
|
||||
|
||||
const chunks = await collectChunks(handler.createMessage("system", messages))
|
||||
|
||||
expect(chunks).to.deep.equal([{ type: "text", text: "chat" }])
|
||||
sinon.assert.calledOnce(chatStub)
|
||||
sinon.assert.notCalled(responsesStub)
|
||||
sinon.assert.notCalled(messagesStub)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,231 +0,0 @@
|
||||
import { afterEach, before, beforeEach, describe, it } from "mocha"
|
||||
import "should"
|
||||
import { ApiHandlerOptions } from "@shared/api"
|
||||
import axios from "axios"
|
||||
import sinon from "sinon"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { OllamaHandler } from "../ollama"
|
||||
|
||||
describe("OllamaHandler", () => {
|
||||
let ollamaAvailable = false
|
||||
|
||||
// Check if Ollama is running before running tests
|
||||
before(async function () {
|
||||
this.timeout(5000)
|
||||
try {
|
||||
await axios.get("http://localhost:11434/api/version", { timeout: 2000 })
|
||||
ollamaAvailable = true
|
||||
} catch (_error) {
|
||||
console.log("Ollama server not available, skipping tests")
|
||||
ollamaAvailable = false
|
||||
}
|
||||
})
|
||||
let handler: OllamaHandler
|
||||
let options: ApiHandlerOptions
|
||||
let clock: sinon.SinonFakeTimers
|
||||
|
||||
beforeEach(() => {
|
||||
options = {
|
||||
actModeOllamaModelId: "llama2",
|
||||
ollamaBaseUrl: "http://localhost:11434",
|
||||
}
|
||||
handler = new OllamaHandler(options)
|
||||
// Use fake timers for testing timeouts
|
||||
clock = sinon.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clock.restore()
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
describe("createMessage", () => {
|
||||
it("should handle successful responses", async function () {
|
||||
if (!ollamaAvailable) {
|
||||
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(client, "chat").resolves({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
message: { content: "Hello, world!" },
|
||||
eval_count: 10,
|
||||
prompt_eval_count: 20,
|
||||
}
|
||||
},
|
||||
} as any)
|
||||
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: ClineStorageMessage[] = [{ role: "user", content: "Hello" }]
|
||||
|
||||
const result = []
|
||||
const usageInfo = []
|
||||
|
||||
// Collect the results
|
||||
for await (const chunk of handler.createMessage(systemPrompt, messages)) {
|
||||
if (chunk.type === "text") {
|
||||
result.push(chunk.text)
|
||||
} else if (chunk.type === "usage") {
|
||||
usageInfo.push({
|
||||
inputTokens: chunk.inputTokens,
|
||||
outputTokens: chunk.outputTokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the results
|
||||
result.should.deepEqual(["Hello, world!"])
|
||||
usageInfo.should.deepEqual([{ inputTokens: 20, outputTokens: 10 }])
|
||||
chatStub.calledOnce.should.be.true()
|
||||
})
|
||||
|
||||
it("should handle timeout errors", async function () {
|
||||
if (!ollamaAvailable) {
|
||||
this.skip()
|
||||
}
|
||||
this.timeout(10000)
|
||||
// Restore real timers for this test
|
||||
clock.restore()
|
||||
|
||||
// Create a handler with a very short timeout for testing
|
||||
const testHandler = new OllamaHandler(options)
|
||||
|
||||
// Replace the createMessage method with one that has a shorter timeout
|
||||
testHandler.createMessage = async function* (_systemPrompt, _messages) {
|
||||
try {
|
||||
// Create a promise that rejects after a short timeout
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("Ollama request timed out after 120 seconds")), 100)
|
||||
})
|
||||
|
||||
// Create a promise that never resolves
|
||||
const neverPromise = new Promise(() => {})
|
||||
|
||||
// Race them
|
||||
await Promise.race([timeoutPromise, neverPromise])
|
||||
} catch (error: any) {
|
||||
// Enhance error reporting
|
||||
console.error(`Ollama API error: ${error.message}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: ClineStorageMessage[] = [{ role: "user", content: "Hello" }]
|
||||
|
||||
// Start the request and catch the error
|
||||
let errorMessage = ""
|
||||
try {
|
||||
for await (const _ of testHandler.createMessage(systemPrompt, messages)) {
|
||||
// This should not be reached
|
||||
}
|
||||
} catch (error: any) {
|
||||
errorMessage = error.message
|
||||
}
|
||||
|
||||
// Check the result
|
||||
errorMessage.should.equal("Ollama request timed out after 120 seconds")
|
||||
|
||||
// Restore the fake timers for other tests
|
||||
clock = sinon.useFakeTimers()
|
||||
})
|
||||
|
||||
it("should retry on errors when using the withRetry decorator", async function () {
|
||||
if (!ollamaAvailable) {
|
||||
this.skip()
|
||||
}
|
||||
this.timeout(10000)
|
||||
// Restore real timers for this test
|
||||
clock.restore()
|
||||
|
||||
// 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"))
|
||||
|
||||
// Second call succeeds
|
||||
chatStub.onSecondCall().resolves({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
message: { content: "Success after retry" },
|
||||
}
|
||||
},
|
||||
} as any)
|
||||
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: ClineStorageMessage[] = [{ role: "user", content: "Hello" }]
|
||||
|
||||
const result = []
|
||||
|
||||
// Add a small delay to ensure the retry mechanism has time to work
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// Collect the results
|
||||
for await (const chunk of handler.createMessage(systemPrompt, messages)) {
|
||||
if (chunk.type === "text") {
|
||||
result.push(chunk.text)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the results
|
||||
result.should.deepEqual(["Success after retry"])
|
||||
chatStub.calledTwice.should.be.true()
|
||||
|
||||
// Restore the fake timers for other tests
|
||||
clock = sinon.useFakeTimers()
|
||||
})
|
||||
|
||||
it("should handle stream processing errors", async function () {
|
||||
if (!ollamaAvailable) {
|
||||
this.skip()
|
||||
}
|
||||
this.timeout(10000)
|
||||
// Restore real timers for this test
|
||||
clock.restore()
|
||||
|
||||
// Create a handler with a custom implementation for testing
|
||||
const testHandler = new OllamaHandler(options)
|
||||
|
||||
// Replace the createMessage method with one that simulates a stream error
|
||||
testHandler.createMessage = async function* (_systemPrompt, _messages) {
|
||||
// First yield a successful chunk
|
||||
yield {
|
||||
type: "text",
|
||||
text: "Partial response",
|
||||
}
|
||||
|
||||
// Then throw an error in the stream
|
||||
throw new Error("Ollama stream processing error: Stream error")
|
||||
}
|
||||
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: ClineStorageMessage[] = [{ role: "user", content: "Hello" }]
|
||||
|
||||
const result = []
|
||||
|
||||
// Collect the results and catch the error
|
||||
let errorMessage = ""
|
||||
try {
|
||||
for await (const chunk of testHandler.createMessage(systemPrompt, messages)) {
|
||||
if (chunk.type === "text") {
|
||||
result.push(chunk.text)
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
errorMessage = error.message
|
||||
}
|
||||
|
||||
// Verify the results
|
||||
errorMessage.should.equal("Ollama stream processing error: Stream error")
|
||||
result.should.deepEqual(["Partial response"])
|
||||
|
||||
// Restore the fake timers for other tests
|
||||
clock = sinon.useFakeTimers()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,164 +0,0 @@
|
||||
import "should"
|
||||
import { openRouterDefaultModelInfo } from "@shared/api"
|
||||
import sinon from "sinon"
|
||||
import { OpenRouterHandler } from "../openrouter"
|
||||
|
||||
describe("OpenRouterHandler", () => {
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
const createAsyncIterable = (data: any[] = []) => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield* data
|
||||
},
|
||||
})
|
||||
|
||||
const tools = [{ type: "function", function: { name: "read_file", description: "", parameters: { type: "object" } } }] as any
|
||||
|
||||
it("should handle usage-only chunks when delta is missing", async () => {
|
||||
const handler = new OpenRouterHandler({
|
||||
openRouterApiKey: "test-api-key",
|
||||
})
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
choices: [{}],
|
||||
usage: {
|
||||
prompt_tokens: 13,
|
||||
completion_tokens: 5,
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
|
||||
sinon.stub(handler, "getModel").returns({
|
||||
id: "openai/gpt-4o-mini",
|
||||
info: openRouterDefaultModelInfo,
|
||||
})
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
chunks.should.deepEqual([
|
||||
{
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
inputTokens: 13,
|
||||
outputTokens: 5,
|
||||
totalCost: 0,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should read cache_write_tokens from prompt_tokens_details", async () => {
|
||||
const handler = new OpenRouterHandler({
|
||||
openRouterApiKey: "test-api-key",
|
||||
})
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
choices: [{}],
|
||||
usage: {
|
||||
prompt_tokens: 1000,
|
||||
completion_tokens: 200,
|
||||
prompt_tokens_details: {
|
||||
cached_tokens: 500,
|
||||
cache_write_tokens: 300,
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
|
||||
sinon.stub(handler, "getModel").returns({
|
||||
id: "anthropic/claude-sonnet-4.6",
|
||||
info: openRouterDefaultModelInfo,
|
||||
})
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
chunks.should.deepEqual([
|
||||
{
|
||||
type: "usage",
|
||||
cacheWriteTokens: 300,
|
||||
cacheReadTokens: 500,
|
||||
inputTokens: 200,
|
||||
outputTokens: 200,
|
||||
totalCost: 0,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
type ParallelToolCallsTestCase = {
|
||||
modelId: string
|
||||
enableParallelToolCalling: boolean
|
||||
expectedParallelToolCalls: boolean
|
||||
}
|
||||
|
||||
const parallelToolCallsTestCases: ParallelToolCallsTestCase[] = [
|
||||
{
|
||||
modelId: "openai/gpt-4o-mini",
|
||||
enableParallelToolCalling: true,
|
||||
expectedParallelToolCalls: true,
|
||||
},
|
||||
{
|
||||
modelId: "openai/gpt-4o-mini",
|
||||
enableParallelToolCalling: false,
|
||||
expectedParallelToolCalls: false,
|
||||
},
|
||||
{
|
||||
modelId: "google/gemini-3-flash-preview",
|
||||
enableParallelToolCalling: true,
|
||||
expectedParallelToolCalls: true,
|
||||
},
|
||||
]
|
||||
|
||||
for (const testCase of parallelToolCallsTestCases) {
|
||||
const settingLabel = testCase.enableParallelToolCalling ? "enabled" : "disabled"
|
||||
it(`should set parallel_tool_calls=${testCase.expectedParallelToolCalls} for ${testCase.modelId} when setting is ${settingLabel}`, async () => {
|
||||
const handler = new OpenRouterHandler({
|
||||
openRouterApiKey: "test-api-key",
|
||||
enableParallelToolCalling: testCase.enableParallelToolCalling,
|
||||
})
|
||||
const createStub = sinon.stub().resolves(createAsyncIterable([]))
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: createStub,
|
||||
},
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
|
||||
sinon.stub(handler, "getModel").returns({
|
||||
id: testCase.modelId,
|
||||
info: openRouterDefaultModelInfo,
|
||||
})
|
||||
|
||||
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "hi" }], tools)) {
|
||||
// drain stream
|
||||
}
|
||||
|
||||
const payload = createStub.firstCall.args[0]
|
||||
payload.parallel_tool_calls.should.equal(testCase.expectedParallelToolCalls)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1,132 +0,0 @@
|
||||
import "should"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { SapAiCoreHandler } from "../sapaicore"
|
||||
|
||||
describe("SapAiCoreHandler", () => {
|
||||
let handler: SapAiCoreHandler
|
||||
|
||||
beforeEach(() => {
|
||||
const mockOptions = {
|
||||
sapAiCoreClientId: "test-client-id",
|
||||
sapAiCoreClientSecret: "test-client-secret",
|
||||
sapAiCoreTokenUrl: "https://test.auth.sap.com",
|
||||
sapAiResourceGroup: "default",
|
||||
sapAiCoreBaseUrl: "https://test.api.sap.com",
|
||||
apiModelId: "anthropic--claude-3.5-sonnet",
|
||||
}
|
||||
handler = new SapAiCoreHandler(mockOptions)
|
||||
})
|
||||
|
||||
describe("image processing", () => {
|
||||
// Test image processing through the public interface
|
||||
// This tests the complete flow including processImageContent internally
|
||||
|
||||
it("should handle image processing for Claude 4 models", () => {
|
||||
// Create handler with Claude 4 model
|
||||
const claude4Handler = new SapAiCoreHandler({
|
||||
sapAiCoreClientId: "test-client-id",
|
||||
sapAiCoreClientSecret: "test-client-secret",
|
||||
sapAiCoreTokenUrl: "https://test.auth.sap.com",
|
||||
sapAiResourceGroup: "default",
|
||||
sapAiCoreBaseUrl: "https://test.api.sap.com",
|
||||
apiModelId: "anthropic--claude-4-sonnet",
|
||||
})
|
||||
|
||||
const model = claude4Handler.getModel()
|
||||
model.id.should.equal("anthropic--claude-4-sonnet")
|
||||
model.info.should.have.property("supportsImages", true)
|
||||
})
|
||||
|
||||
it("should create proper user readable request with images", () => {
|
||||
const testImageData =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
|
||||
|
||||
const userContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] = [
|
||||
{
|
||||
type: "text",
|
||||
text: "Here's an image:",
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: "image/png",
|
||||
data: testImageData,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const result = handler.createUserReadableRequest(userContent)
|
||||
|
||||
result.should.have.property("model")
|
||||
result.should.have.property("max_tokens")
|
||||
result.should.have.property("system")
|
||||
result.should.have.property("messages")
|
||||
result.messages.should.be.Array()
|
||||
result.messages[1].should.have.property("role", "user")
|
||||
result.messages[1].should.have.property("content", userContent)
|
||||
})
|
||||
|
||||
it("should support different Claude model variants", () => {
|
||||
const modelVariants = [
|
||||
"anthropic--claude-4.6-sonnet",
|
||||
"anthropic--claude-4-sonnet",
|
||||
"anthropic--claude-4-opus",
|
||||
"anthropic--claude-3.7-sonnet",
|
||||
"anthropic--claude-3.5-sonnet",
|
||||
"anthropic--claude-3-sonnet",
|
||||
"anthropic--claude-3-haiku",
|
||||
"anthropic--claude-3-opus",
|
||||
]
|
||||
|
||||
modelVariants.forEach((modelId) => {
|
||||
const testHandler = new SapAiCoreHandler({
|
||||
apiModelId: modelId,
|
||||
})
|
||||
|
||||
const model = testHandler.getModel()
|
||||
model.id.should.equal(modelId)
|
||||
model.info.should.have.property("maxTokens")
|
||||
model.info.should.have.property("contextWindow")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("getModel", () => {
|
||||
it("should return default model when no apiModelId is provided", () => {
|
||||
const result = handler.getModel()
|
||||
result.should.have.property("id")
|
||||
result.should.have.property("info")
|
||||
result.info.should.have.property("maxTokens")
|
||||
})
|
||||
|
||||
it("should return specified model when apiModelId is provided", () => {
|
||||
const customHandler = new SapAiCoreHandler({
|
||||
apiModelId: "anthropic--claude-4-sonnet",
|
||||
})
|
||||
|
||||
const result = customHandler.getModel()
|
||||
result.id.should.equal("anthropic--claude-4-sonnet")
|
||||
})
|
||||
})
|
||||
|
||||
describe("createUserReadableRequest", () => {
|
||||
it("should create a readable request format", () => {
|
||||
const userContent: Anthropic.TextBlockParam[] = [
|
||||
{
|
||||
type: "text",
|
||||
text: "Hello, world!",
|
||||
},
|
||||
]
|
||||
|
||||
const result = handler.createUserReadableRequest(userContent)
|
||||
|
||||
result.should.have.property("model")
|
||||
result.should.have.property("max_tokens")
|
||||
result.should.have.property("system")
|
||||
result.should.have.property("messages")
|
||||
result.should.have.property("tools")
|
||||
result.should.have.property("tool_choice")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,138 +0,0 @@
|
||||
import "should"
|
||||
import { openRouterDefaultModelId, openRouterDefaultModelInfo } from "@shared/api"
|
||||
import sinon from "sinon"
|
||||
import { VercelAIGatewayHandler } from "../vercel-ai-gateway"
|
||||
|
||||
describe("VercelAIGatewayHandler", () => {
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
const createAsyncIterable = (data: any[] = []) => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield* data
|
||||
},
|
||||
})
|
||||
|
||||
describe("getModel", () => {
|
||||
it("should return configured model and info when both are provided", () => {
|
||||
const customModelInfo = {
|
||||
...openRouterDefaultModelInfo,
|
||||
maxTokens: 123456,
|
||||
}
|
||||
|
||||
const handler = new VercelAIGatewayHandler({
|
||||
openRouterModelId: "google/gemini-3.1-pro-preview",
|
||||
openRouterModelInfo: customModelInfo,
|
||||
})
|
||||
|
||||
const result = handler.getModel()
|
||||
result.id.should.equal("google/gemini-3.1-pro-preview")
|
||||
result.info.should.deepEqual(customModelInfo)
|
||||
})
|
||||
|
||||
it("should preserve configured model ID when model info is missing", () => {
|
||||
const handler = new VercelAIGatewayHandler({
|
||||
openRouterModelId: "google/gemini-3.1-pro-preview",
|
||||
})
|
||||
|
||||
const result = handler.getModel()
|
||||
result.id.should.equal("google/gemini-3.1-pro-preview")
|
||||
result.info.should.deepEqual(openRouterDefaultModelInfo)
|
||||
})
|
||||
|
||||
it("should fall back to default model when model ID is missing", () => {
|
||||
const handler = new VercelAIGatewayHandler({})
|
||||
const result = handler.getModel()
|
||||
|
||||
result.id.should.equal(openRouterDefaultModelId)
|
||||
result.info.should.deepEqual(openRouterDefaultModelInfo)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createMessage", () => {
|
||||
it("should handle usage-only chunks when delta is missing", async () => {
|
||||
const handler = new VercelAIGatewayHandler({
|
||||
vercelAiGatewayApiKey: "test-api-key",
|
||||
})
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
choices: [{}],
|
||||
usage: {
|
||||
prompt_tokens: 11,
|
||||
completion_tokens: 7,
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
chunks.should.deepEqual([
|
||||
{
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
inputTokens: 11,
|
||||
outputTokens: 7,
|
||||
totalCost: 0,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should read Anthropic-style cache creation and read tokens from usage chunks", async () => {
|
||||
const handler = new VercelAIGatewayHandler({
|
||||
vercelAiGatewayApiKey: "test-api-key",
|
||||
})
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
choices: [{}],
|
||||
usage: {
|
||||
prompt_tokens: 1000,
|
||||
completion_tokens: 200,
|
||||
prompt_tokens_details: {
|
||||
cached_tokens: 500,
|
||||
},
|
||||
cache_creation_input_tokens: 300,
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
chunks.should.deepEqual([
|
||||
{
|
||||
type: "usage",
|
||||
cacheWriteTokens: 300,
|
||||
cacheReadTokens: 500,
|
||||
inputTokens: 200,
|
||||
outputTokens: 200,
|
||||
totalCost: 0,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,23 +0,0 @@
|
||||
import "should"
|
||||
import { vertexGlobalModels } from "@shared/api"
|
||||
import { VertexHandler } from "../vertex"
|
||||
|
||||
describe("VertexHandler", () => {
|
||||
it("supports Gemini 3.5 Flash model metadata", () => {
|
||||
const handler = new VertexHandler({
|
||||
vertexProjectId: "test-project",
|
||||
vertexRegion: "global",
|
||||
apiModelId: "gemini-3.5-flash",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("gemini-3.5-flash")
|
||||
model.info.contextWindow!.should.equal(1_048_576)
|
||||
model.info.inputPrice!.should.equal(1.5)
|
||||
model.info.outputPrice!.should.equal(9)
|
||||
model.info.cacheReadsPrice!.should.equal(0.15)
|
||||
model.info.supportsGlobalEndpoint!.should.equal(true)
|
||||
model.info.supportsReasoning!.should.equal(true)
|
||||
vertexGlobalModels.should.have.property("gemini-3.5-flash")
|
||||
})
|
||||
})
|
||||
@@ -1,42 +0,0 @@
|
||||
import "should"
|
||||
import { openAiModelInfoSaneDefaults, wandbDefaultModelId, wandbModels } from "@shared/api"
|
||||
import { WandbHandler } from "../wandb"
|
||||
|
||||
describe("WandbHandler", () => {
|
||||
it("returns known catalog model metadata when model id is recognized", () => {
|
||||
const modelId = "meta-llama/Llama-3.3-70B-Instruct"
|
||||
const handler = new WandbHandler({
|
||||
wandbApiKey: "test-api-key",
|
||||
apiModelId: modelId,
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
|
||||
model.id.should.equal(modelId)
|
||||
model.info.should.deepEqual(wandbModels[modelId])
|
||||
})
|
||||
|
||||
it("passes through an explicit unknown model id instead of silently falling back", () => {
|
||||
const unknownModelId = "moonshotai/Kimi-K2.5"
|
||||
const handler = new WandbHandler({
|
||||
wandbApiKey: "test-api-key",
|
||||
apiModelId: unknownModelId,
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
|
||||
model.id.should.equal(unknownModelId)
|
||||
model.info.should.deepEqual(openAiModelInfoSaneDefaults)
|
||||
})
|
||||
|
||||
it("uses the default W&B model when no model id is configured", () => {
|
||||
const handler = new WandbHandler({
|
||||
wandbApiKey: "test-api-key",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
|
||||
model.id.should.equal(wandbDefaultModelId)
|
||||
model.info.should.deepEqual(wandbModels[wandbDefaultModelId])
|
||||
})
|
||||
})
|
||||
@@ -1,327 +0,0 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { GenerateContentConfig, GoogleGenAI } from "@google/genai"
|
||||
import { ModelInfo } from "@shared/api"
|
||||
import OpenAI from "openai"
|
||||
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from "../index"
|
||||
import { withRetry } from "../retry"
|
||||
import { sanitizeAnthropicMessages } from "../transform/anthropic-format"
|
||||
import { convertAnthropicMessageToGemini } from "../transform/gemini-format"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
|
||||
interface AIhubmixHandlerOptions extends CommonApiHandlerOptions {
|
||||
apiKey?: string
|
||||
baseURL?: string
|
||||
appCode?: string
|
||||
modelId?: string
|
||||
modelInfo?: ModelInfo
|
||||
thinkingBudgetTokens?: number
|
||||
}
|
||||
|
||||
export class AIhubmixHandler implements ApiHandler {
|
||||
private options: AIhubmixHandlerOptions
|
||||
private anthropicClient: Anthropic | undefined
|
||||
private openaiClient: OpenAI | undefined
|
||||
private geminiClient: GoogleGenAI | undefined
|
||||
|
||||
constructor(options: AIhubmixHandlerOptions) {
|
||||
const { baseURL, appCode, ...rest } = options
|
||||
this.options = {
|
||||
baseURL: baseURL ?? "https://aihubmix.com",
|
||||
appCode: appCode ?? "KUWF9311",
|
||||
...rest,
|
||||
}
|
||||
}
|
||||
|
||||
private ensureAnthropicClient(): Anthropic {
|
||||
if (!this.anthropicClient) {
|
||||
if (!this.options.apiKey) {
|
||||
throw new Error("AIhubmix API key is required")
|
||||
}
|
||||
try {
|
||||
this.anthropicClient = new Anthropic({
|
||||
apiKey: this.options.apiKey,
|
||||
baseURL: this.options.baseURL,
|
||||
defaultHeaders: {
|
||||
"APP-Code": this.options.appCode,
|
||||
...buildExternalBasicHeaders(),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(`Error creating Anthropic client: ${error.message}`)
|
||||
}
|
||||
}
|
||||
return this.anthropicClient
|
||||
}
|
||||
|
||||
private ensureOpenaiClient(): OpenAI {
|
||||
if (!this.openaiClient) {
|
||||
if (!this.options.apiKey) {
|
||||
throw new Error("AIhubmix API key is required")
|
||||
}
|
||||
try {
|
||||
this.openaiClient = new OpenAI({
|
||||
apiKey: this.options.apiKey,
|
||||
baseURL: `${this.options.baseURL}/v1`,
|
||||
defaultHeaders: {
|
||||
"APP-Code": this.options.appCode,
|
||||
...buildExternalBasicHeaders(),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(`Error creating OpenAI client: ${error.message}`)
|
||||
}
|
||||
}
|
||||
return this.openaiClient
|
||||
}
|
||||
|
||||
private ensureGeminiClient(): GoogleGenAI {
|
||||
if (!this.geminiClient) {
|
||||
if (!this.options.apiKey) {
|
||||
throw new Error("AIhubmix API key is required")
|
||||
}
|
||||
try {
|
||||
this.geminiClient = new GoogleGenAI({
|
||||
apiKey: this.options.apiKey,
|
||||
httpOptions: {
|
||||
baseUrl: `${this.options.baseURL}/gemini`,
|
||||
headers: {
|
||||
// @ts-expect-error
|
||||
"APP-Code": this.options.appCode,
|
||||
Authorization: `Bearer ${this.options.apiKey ?? ""}`,
|
||||
...buildExternalBasicHeaders(),
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(`Error creating Gemini client: ${error.message}`)
|
||||
}
|
||||
}
|
||||
return this.geminiClient
|
||||
}
|
||||
|
||||
private routeModel(modelName: string): "anthropic" | "openai" | "gemini" | "openai-response" {
|
||||
const id = modelName || ""
|
||||
if (id.startsWith("claude")) {
|
||||
return "anthropic"
|
||||
}
|
||||
if (id.startsWith("gemini") && !id.endsWith("-nothink") && !id.endsWith("-search")) {
|
||||
return "gemini"
|
||||
}
|
||||
if (id === "gpt-5-pro" || id === "gpt-5-codex") {
|
||||
return "openai-response"
|
||||
}
|
||||
return "openai"
|
||||
}
|
||||
|
||||
private fixToolChoice(requestBody: any): any {
|
||||
if (requestBody.tools?.length === 0 && requestBody.tool_choice) {
|
||||
delete requestBody.tool_choice
|
||||
}
|
||||
return requestBody
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: any[]): ApiStream {
|
||||
const modelId = this.options.modelId || ""
|
||||
const route = this.routeModel(modelId)
|
||||
|
||||
switch (route) {
|
||||
case "anthropic":
|
||||
yield* this.createAnthropicMessage(systemPrompt, messages)
|
||||
break
|
||||
case "gemini":
|
||||
yield* this.createGeminiMessage(systemPrompt, messages)
|
||||
break
|
||||
case "openai-response":
|
||||
yield* this.createOpenaiResponseMessage(systemPrompt, messages)
|
||||
break
|
||||
case "openai":
|
||||
yield* this.createOpenaiMessage(systemPrompt, messages)
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported model route: ${route}`)
|
||||
}
|
||||
}
|
||||
|
||||
private async *createAnthropicMessage(systemPrompt: string, messages: any[]): ApiStream {
|
||||
const client = this.ensureAnthropicClient()
|
||||
const modelId = this.options.modelId || "claude-3-5-sonnet-20241022"
|
||||
|
||||
// Sanitize messages to remove Cline-specific fields like call_id that are not allowed by Anthropic API
|
||||
const sanitizedMessages = sanitizeAnthropicMessages(messages, false)
|
||||
|
||||
const stream = await client.messages.create({
|
||||
model: modelId,
|
||||
temperature: 0,
|
||||
max_tokens: this.options.modelInfo?.maxTokens || 8192,
|
||||
system: [{ text: systemPrompt, type: "text" }],
|
||||
messages: sanitizedMessages,
|
||||
stream: true,
|
||||
})
|
||||
|
||||
for await (const chunk of stream) {
|
||||
switch (chunk?.type) {
|
||||
case "message_start":
|
||||
const usage = chunk.message.usage
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: usage.input_tokens || 0,
|
||||
outputTokens: usage.output_tokens || 0,
|
||||
cacheWriteTokens: usage.cache_creation_input_tokens || undefined,
|
||||
cacheReadTokens: usage.cache_read_input_tokens || undefined,
|
||||
}
|
||||
break
|
||||
case "message_delta":
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: 0,
|
||||
outputTokens: chunk.usage.output_tokens || 0,
|
||||
}
|
||||
break
|
||||
case "content_block_start":
|
||||
if (chunk.content_block.type === "text") {
|
||||
yield {
|
||||
type: "text",
|
||||
text: chunk.content_block.text,
|
||||
}
|
||||
}
|
||||
break
|
||||
case "content_block_delta":
|
||||
if (chunk.delta.type === "text_delta") {
|
||||
yield {
|
||||
type: "text",
|
||||
text: chunk.delta.text,
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async *createOpenaiResponseMessage(systemPrompt: string, messages: any[]): ApiStream {
|
||||
const client = this.ensureOpenaiClient()
|
||||
const modelId = this.options.modelId || "gpt-4o-mini"
|
||||
|
||||
const input = (messages || []).map((m: any) => {
|
||||
const role = m.role || "user"
|
||||
const contentArray = Array.isArray(m.content) ? m.content : [{ type: "text", text: m.content }]
|
||||
const content = contentArray
|
||||
.filter((c: any) => c != null)
|
||||
.map((c: any) => {
|
||||
if (c.type === "image" || c.type === "input_image" || c.type === "image_url") {
|
||||
return { type: "input_image", image_url: c.image_url || c.url || c.source?.url }
|
||||
}
|
||||
const text = c.text ?? (typeof c === "string" ? c : "")
|
||||
return { type: role === "assistant" ? "output_text" : "input_text", text }
|
||||
})
|
||||
return { role, content }
|
||||
})
|
||||
|
||||
const stream = await (client as any).responses.stream({
|
||||
model: modelId,
|
||||
instructions: systemPrompt,
|
||||
input,
|
||||
})
|
||||
|
||||
for await (const event of stream as any) {
|
||||
if (event?.type === "response.output_text.delta") {
|
||||
yield { type: "text", text: event.delta || "" }
|
||||
continue
|
||||
}
|
||||
if (event?.type === "response.completed") {
|
||||
const usage = event.response?.usage || {}
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: usage.input_tokens || 0,
|
||||
outputTokens: usage.output_tokens || 0,
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (event?.type === "response.error") {
|
||||
throw new Error(event.error?.message || "responses error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async *createOpenaiMessage(systemPrompt: string, messages: any[]): ApiStream {
|
||||
const client = this.ensureOpenaiClient()
|
||||
const modelId = this.options.modelId || "gpt-4o-mini"
|
||||
|
||||
const openaiMessages = [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)]
|
||||
|
||||
const requestBody = {
|
||||
model: modelId,
|
||||
messages: openaiMessages,
|
||||
temperature: 0,
|
||||
stream: true,
|
||||
}
|
||||
|
||||
const fixedRequestBody = this.fixToolChoice(requestBody)
|
||||
|
||||
const stream = await client.chat.completions.create(fixedRequestBody)
|
||||
|
||||
for await (const chunk of stream as any) {
|
||||
const delta = chunk.choices?.[0]?.delta
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async *createGeminiMessage(systemPrompt: string, messages: any[]): ApiStream {
|
||||
const client = this.ensureGeminiClient()
|
||||
const modelId = this.options.modelId || "gemini-2.0-flash-exp"
|
||||
|
||||
const contents = messages.map(convertAnthropicMessageToGemini)
|
||||
|
||||
const requestConfig: GenerateContentConfig = {
|
||||
systemInstruction: systemPrompt,
|
||||
temperature: 0,
|
||||
}
|
||||
|
||||
if (this.options.thinkingBudgetTokens) {
|
||||
requestConfig.thinkingConfig = {
|
||||
thinkingBudget: this.options.thinkingBudgetTokens,
|
||||
includeThoughts: true,
|
||||
}
|
||||
}
|
||||
|
||||
const stream = await client.models.generateContentStream({
|
||||
model: modelId,
|
||||
contents,
|
||||
config: requestConfig,
|
||||
})
|
||||
|
||||
for await (const chunk of stream as any) {
|
||||
if (chunk?.text) {
|
||||
yield { type: "text", text: chunk.text }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getModel(): { id: string; info: ModelInfo } {
|
||||
return {
|
||||
id: this.options.modelId || "gpt-4o-mini",
|
||||
info: this.options.modelInfo || {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 128000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
description: "AIhubmix unified model provider",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import type {
|
||||
MessageCreateParamsStreaming as BetaMessageCreateParamsStreaming,
|
||||
BetaRawMessageStreamEvent,
|
||||
} from "@anthropic-ai/sdk/resources/beta/messages/messages"
|
||||
import { Tool as AnthropicTool } from "@anthropic-ai/sdk/resources/index"
|
||||
import type { MessageCreateParamsStreaming as AnthropicMessageCreateParamsStreaming } from "@anthropic-ai/sdk/resources/messages/messages"
|
||||
import { Stream as AnthropicStream } from "@anthropic-ai/sdk/streaming"
|
||||
import {
|
||||
ANTHROPIC_FAST_MODE_SUFFIX,
|
||||
AnthropicModelId,
|
||||
anthropicDefaultModelId,
|
||||
anthropicModels,
|
||||
CLAUDE_SONNET_1M_SUFFIX,
|
||||
ModelInfo,
|
||||
} from "@shared/api"
|
||||
import { isClaudeOpusAdaptiveThinkingModel, resolveClaudeOpusAdaptiveThinking } from "@shared/utils/reasoning-support"
|
||||
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { fetch } from "@/shared/net"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from "../index"
|
||||
import { withRetry } from "../retry"
|
||||
import { sanitizeAnthropicMessages } from "../transform/anthropic-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
|
||||
export const ANTHROPIC_FAST_MODE_BETA = "fast-mode-2026-02-01"
|
||||
|
||||
interface AnthropicHandlerOptions extends CommonApiHandlerOptions {
|
||||
apiKey?: string
|
||||
anthropicBaseUrl?: string
|
||||
apiModelId?: string
|
||||
reasoningEffort?: string
|
||||
thinkingBudgetTokens?: number
|
||||
}
|
||||
|
||||
export class AnthropicHandler implements ApiHandler {
|
||||
private options: AnthropicHandlerOptions
|
||||
private client: Anthropic | undefined
|
||||
|
||||
constructor(options: AnthropicHandlerOptions) {
|
||||
this.options = options
|
||||
}
|
||||
|
||||
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,
|
||||
defaultHeaders: buildExternalBasicHeaders(),
|
||||
fetch, // Use configured fetch with proxy support
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(`Error creating Anthropic client: ${error.message}`)
|
||||
}
|
||||
}
|
||||
return this.client
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: AnthropicTool[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
|
||||
const model = this.getModel()
|
||||
let stream: AnthropicStream<Anthropic.RawMessageStreamEvent> | AsyncIterable<BetaRawMessageStreamEvent>
|
||||
|
||||
const useFastMode = model.id.endsWith(ANTHROPIC_FAST_MODE_SUFFIX)
|
||||
const baseModelId = useFastMode ? model.id.slice(0, -ANTHROPIC_FAST_MODE_SUFFIX.length) : model.id
|
||||
const modelId = baseModelId.endsWith(CLAUDE_SONNET_1M_SUFFIX)
|
||||
? baseModelId.slice(0, -CLAUDE_SONNET_1M_SUFFIX.length)
|
||||
: baseModelId
|
||||
const enable1mContextWindow = baseModelId.endsWith(CLAUDE_SONNET_1M_SUFFIX)
|
||||
const fastModeBetas = enable1mContextWindow
|
||||
? [ANTHROPIC_FAST_MODE_BETA, "context-1m-2025-08-07"]
|
||||
: [ANTHROPIC_FAST_MODE_BETA]
|
||||
const createFastModeMessage = (
|
||||
body: AnthropicMessageCreateParamsStreaming,
|
||||
): Promise<AsyncIterable<BetaRawMessageStreamEvent>> => {
|
||||
return (
|
||||
client.beta.messages.create as unknown as (
|
||||
params: BetaMessageCreateParamsStreaming & { speed: "fast" },
|
||||
) => Promise<AsyncIterable<BetaRawMessageStreamEvent>>
|
||||
)({
|
||||
...body,
|
||||
betas: fastModeBetas,
|
||||
speed: "fast",
|
||||
})
|
||||
}
|
||||
|
||||
const budget_tokens = this.options.thinkingBudgetTokens || 0
|
||||
|
||||
// Tools are available only when native tools are enabled.
|
||||
const nativeToolsOn = tools?.length && tools?.length > 0
|
||||
const reasoningOn = (model.info.supportsReasoning ?? false) && budget_tokens !== 0
|
||||
|
||||
// Claude Opus 4.5+ uses adaptive thinking instead of budgeted extended thinking.
|
||||
const isAdaptiveThinkingModel = isClaudeOpusAdaptiveThinkingModel(modelId)
|
||||
const adaptiveThinking = isAdaptiveThinkingModel
|
||||
? resolveClaudeOpusAdaptiveThinking(this.options.reasoningEffort, budget_tokens)
|
||||
: undefined
|
||||
const adaptiveThinkingEnabled = adaptiveThinking?.enabled === true
|
||||
const adaptiveThinkingEffort = adaptiveThinking?.effort
|
||||
const thinkingEnabled = isAdaptiveThinkingModel ? adaptiveThinkingEnabled : reasoningOn
|
||||
const thinkingConfig = thinkingEnabled
|
||||
? isAdaptiveThinkingModel
|
||||
? ({ type: "adaptive" } as any)
|
||||
: { type: "enabled", budget_tokens: budget_tokens }
|
||||
: undefined
|
||||
const outputConfig = isAdaptiveThinkingModel && adaptiveThinkingEffort ? { effort: adaptiveThinkingEffort } : undefined
|
||||
|
||||
if (model.info.supportsPromptCache) {
|
||||
const anthropicMessages = sanitizeAnthropicMessages(messages, true)
|
||||
const requestBody: AnthropicMessageCreateParamsStreaming & Record<string, unknown> = {
|
||||
model: modelId,
|
||||
thinking: thinkingConfig,
|
||||
max_tokens: model.info.maxTokens || 8192,
|
||||
// "Thinking isn't compatible with temperature, top_p, or top_k modifications as well as forced tool use."
|
||||
// (https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking)
|
||||
// Adaptive Claude Opus models do not support temperature.
|
||||
temperature: isAdaptiveThinkingModel ? undefined : reasoningOn ? undefined : 0,
|
||||
system: [
|
||||
{
|
||||
text: systemPrompt,
|
||||
type: "text",
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
], // setting cache breakpoint for system prompt so new tasks can reuse it
|
||||
messages: anthropicMessages,
|
||||
// tools, // cache breakpoints go from tools > system > messages, and since tools dont change, we can just set the breakpoint at the end of system (this avoids having to set a breakpoint at the end of tools which by itself does not meet min requirements for haiku caching)
|
||||
stream: true,
|
||||
tools: nativeToolsOn ? tools : undefined,
|
||||
// tool_choice options:
|
||||
// - none: disables tool use, even if tools are provided. Claude will not call any tools.
|
||||
// - auto: allows Claude to decide whether to call any provided tools or not. This is the default value when tools are provided.
|
||||
// - any: tells Claude that it must use one of the provided tools, but doesn’t force a particular tool.
|
||||
// NOTE: Forcing tool use when tools are provided will result in error when thinking is also enabled.
|
||||
tool_choice: nativeToolsOn && !thinkingEnabled ? { type: "any" } : undefined,
|
||||
}
|
||||
if (outputConfig) {
|
||||
requestBody.output_config = outputConfig
|
||||
}
|
||||
|
||||
stream = useFastMode
|
||||
? await createFastModeMessage(requestBody)
|
||||
: await client.messages.create(
|
||||
requestBody,
|
||||
(() => {
|
||||
// 1m context window beta header
|
||||
if (enable1mContextWindow) {
|
||||
return {
|
||||
headers: {
|
||||
"anthropic-beta": "context-1m-2025-08-07",
|
||||
},
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
})(),
|
||||
)
|
||||
} else {
|
||||
const requestBody: AnthropicMessageCreateParamsStreaming & Record<string, unknown> = {
|
||||
model: modelId,
|
||||
max_tokens: model.info.maxTokens || 8192,
|
||||
temperature: isAdaptiveThinkingModel ? undefined : reasoningOn ? undefined : 0,
|
||||
system: [{ text: systemPrompt, type: "text" }],
|
||||
messages: sanitizeAnthropicMessages(messages, false),
|
||||
tools: nativeToolsOn ? tools : undefined,
|
||||
tool_choice: thinkingEnabled ? undefined : { type: "auto" },
|
||||
stream: true,
|
||||
thinking: thinkingConfig,
|
||||
}
|
||||
if (outputConfig) {
|
||||
requestBody.output_config = outputConfig
|
||||
}
|
||||
|
||||
stream = useFastMode ? await createFastModeMessage(requestBody) : await client.messages.create(requestBody)
|
||||
}
|
||||
|
||||
const lastStartedToolCall = { id: "", name: "", arguments: "" }
|
||||
|
||||
for await (const chunk of stream) {
|
||||
switch (chunk?.type) {
|
||||
case "message_start":
|
||||
{
|
||||
// tells us cache reads/writes/input/output
|
||||
const usage = chunk.message.usage
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: usage.input_tokens || 0,
|
||||
outputTokens: usage.output_tokens || 0,
|
||||
cacheWriteTokens: usage.cache_creation_input_tokens || undefined,
|
||||
cacheReadTokens: usage.cache_read_input_tokens || undefined,
|
||||
}
|
||||
}
|
||||
break
|
||||
case "message_delta":
|
||||
// tells us stop_reason, stop_sequence, and output tokens along the way and at the end of the message
|
||||
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: 0,
|
||||
outputTokens: chunk.usage.output_tokens || 0,
|
||||
}
|
||||
break
|
||||
case "message_stop":
|
||||
// no usage data, just an indicator that the message is done
|
||||
break
|
||||
case "content_block_start":
|
||||
switch (chunk.content_block.type) {
|
||||
case "thinking":
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: chunk.content_block.thinking || "",
|
||||
signature: chunk.content_block.signature,
|
||||
}
|
||||
break
|
||||
case "redacted_thinking":
|
||||
// Content is encrypted, and we don't to pass placeholder text back to the API
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: "[Redacted thinking block]",
|
||||
redacted_data: chunk.content_block.data,
|
||||
}
|
||||
break
|
||||
case "tool_use":
|
||||
if (chunk.content_block.id && chunk.content_block.name) {
|
||||
// Convert Anthropic tool_use to OpenAI-compatible format
|
||||
lastStartedToolCall.id = chunk.content_block.id
|
||||
lastStartedToolCall.name = chunk.content_block.name
|
||||
lastStartedToolCall.arguments = ""
|
||||
}
|
||||
break
|
||||
case "text":
|
||||
// we may receive multiple text blocks, in which case just insert a line break between them
|
||||
if (chunk.index > 0) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: "\n",
|
||||
}
|
||||
}
|
||||
yield {
|
||||
type: "text",
|
||||
text: chunk.content_block.text,
|
||||
}
|
||||
break
|
||||
}
|
||||
break
|
||||
case "content_block_delta":
|
||||
switch (chunk.delta.type) {
|
||||
case "thinking_delta":
|
||||
// 'reasoning' type just displays in the UI, but ant_thinking will be used to send the thinking traces back to the API
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: chunk.delta.thinking,
|
||||
}
|
||||
break
|
||||
case "signature_delta":
|
||||
// It's used when sending the thinking block back to the API
|
||||
// API expects this in completed form, not as array of deltas
|
||||
if (chunk.delta.signature) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: "", // reasoning text is already sent via thinking_delta
|
||||
signature: chunk.delta.signature,
|
||||
}
|
||||
}
|
||||
break
|
||||
case "text_delta":
|
||||
yield {
|
||||
type: "text",
|
||||
text: chunk.delta.text,
|
||||
}
|
||||
break
|
||||
case "input_json_delta":
|
||||
if (lastStartedToolCall.id && lastStartedToolCall.name && chunk.delta.partial_json) {
|
||||
// // Convert Anthropic tool_use to OpenAI-compatible format
|
||||
yield {
|
||||
type: "tool_calls",
|
||||
tool_call: {
|
||||
...lastStartedToolCall,
|
||||
function: {
|
||||
...lastStartedToolCall,
|
||||
id: lastStartedToolCall.id,
|
||||
name: lastStartedToolCall.name,
|
||||
arguments: chunk.delta.partial_json,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
break
|
||||
|
||||
case "content_block_stop":
|
||||
lastStartedToolCall.id = ""
|
||||
lastStartedToolCall.name = ""
|
||||
lastStartedToolCall.arguments = ""
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getModel(): { id: AnthropicModelId; info: ModelInfo } {
|
||||
const modelId = this.options.apiModelId
|
||||
if (modelId && modelId in anthropicModels) {
|
||||
const id = modelId as AnthropicModelId
|
||||
return { id, info: anthropicModels[id] }
|
||||
}
|
||||
return {
|
||||
id: anthropicDefaultModelId,
|
||||
info: anthropicModels[anthropicDefaultModelId],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
import { AskSageModelId, askSageDefaultModelId, askSageDefaultURL, askSageModels, ModelInfo } from "@shared/api"
|
||||
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { fetch } from "@/shared/net"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from ".."
|
||||
import { withRetry } from "../retry"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
|
||||
interface AskSageHandlerOptions extends CommonApiHandlerOptions {
|
||||
asksageApiKey?: string
|
||||
asksageApiUrl?: string
|
||||
apiModelId?: string
|
||||
}
|
||||
|
||||
type AskSageRequest = {
|
||||
system_prompt: string
|
||||
message: {
|
||||
user: "gpt" | "me"
|
||||
message: string
|
||||
}[]
|
||||
model: string
|
||||
dataset: "none"
|
||||
usage: boolean
|
||||
}
|
||||
|
||||
type AskSageUsage = {
|
||||
model_tokens: {
|
||||
completion_tokens: number
|
||||
prompt_tokens: number
|
||||
total_tokens: number
|
||||
}
|
||||
asksage_tokens: number
|
||||
}
|
||||
|
||||
type AskSageResponse = {
|
||||
uuid: string
|
||||
status: number
|
||||
// Response status
|
||||
response: string
|
||||
// Generated response message
|
||||
message: string
|
||||
// whether embedding & vector systems are down
|
||||
embedding_down: boolean
|
||||
vectors_down: boolean
|
||||
// references if dataset is not none
|
||||
references: string
|
||||
type: string
|
||||
added_obj: any
|
||||
tool_calls: any
|
||||
// usage metrics
|
||||
usage: AskSageUsage | null
|
||||
tool_responses: any[]
|
||||
tool_calls_unified: any[]
|
||||
}
|
||||
|
||||
export class AskSageHandler implements ApiHandler {
|
||||
private options: AskSageHandlerOptions
|
||||
private apiUrl: string
|
||||
private apiKey: string
|
||||
|
||||
constructor(options: AskSageHandlerOptions) {
|
||||
Logger.log("init api url", options.asksageApiUrl, askSageDefaultURL)
|
||||
this.options = options
|
||||
this.apiKey = options.asksageApiKey || ""
|
||||
this.apiUrl = options.asksageApiUrl || askSageDefaultURL
|
||||
|
||||
if (!this.apiKey) {
|
||||
throw new Error("AskSage API key is required")
|
||||
}
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[]): ApiStream {
|
||||
try {
|
||||
const model = this.getModel()
|
||||
// Transform messages into AskSageRequest format
|
||||
const formattedMessages = messages.map((msg) => {
|
||||
const content = Array.isArray(msg.content)
|
||||
? msg.content.map((block) => ("text" in block ? block.text : "")).join("")
|
||||
: msg.content
|
||||
|
||||
return {
|
||||
user: msg.role === "assistant" ? ("gpt" as const) : ("me" as const),
|
||||
message: content,
|
||||
}
|
||||
})
|
||||
|
||||
const request: AskSageRequest = {
|
||||
system_prompt: systemPrompt,
|
||||
message: formattedMessages,
|
||||
model: model.id,
|
||||
dataset: "none",
|
||||
usage: true,
|
||||
}
|
||||
|
||||
// Make request to AskSage API
|
||||
const response = await fetch(`${this.apiUrl}/query`, {
|
||||
method: "POST",
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
throw new Error(`AskSage API error: ${error}`)
|
||||
}
|
||||
|
||||
const result = (await response.json()) as AskSageResponse
|
||||
|
||||
if (!result.message) {
|
||||
throw new Error("No content in AskSage response")
|
||||
}
|
||||
|
||||
// Yield tool responses if they exist
|
||||
if (result.tool_responses && result.tool_responses.length > 0) {
|
||||
for (const toolResponse of result.tool_responses) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[Tool Response: ${JSON.stringify(toolResponse)}]\n`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Yield the main response text
|
||||
yield {
|
||||
type: "text",
|
||||
text: result.message,
|
||||
}
|
||||
|
||||
// Yield usage information if available
|
||||
if (result.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: result.usage.model_tokens.prompt_tokens,
|
||||
outputTokens: result.usage.model_tokens.completion_tokens,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: result.usage.asksage_tokens, // Cost = Consumed AskSage tokens
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`AskSage request failed: ${error.message}`)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async getApiStreamUsage() {
|
||||
if (!this.apiKey) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.apiUrl}/count-monthly-tokens`, {
|
||||
method: "POST",
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ app_name: "asksage" }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
Logger.error("Failed to fetch AskSage usage", await response.text())
|
||||
return undefined
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const usedTokens = data.response as number
|
||||
|
||||
return {
|
||||
type: "usage" as const,
|
||||
inputTokens: usedTokens,
|
||||
outputTokens: 0,
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error("Error fetching AskSage usage:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
getModel(): { id: string; info: ModelInfo } {
|
||||
const modelId = this.options.apiModelId
|
||||
if (modelId && modelId in askSageModels) {
|
||||
const id = modelId as AskSageModelId
|
||||
return { id, info: askSageModels[id] }
|
||||
}
|
||||
return {
|
||||
id: askSageDefaultModelId,
|
||||
info: askSageModels[askSageDefaultModelId],
|
||||
}
|
||||
}
|
||||
|
||||
private headers() {
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"x-access-tokens": this.apiKey,
|
||||
...buildExternalBasicHeaders(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
import { BasetenModelId, basetenDefaultModelId, basetenModels, ModelInfo } from "@shared/api"
|
||||
import { calculateApiCostOpenAI } from "@utils/cost"
|
||||
import OpenAI from "openai"
|
||||
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
|
||||
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { fetch } from "@/shared/net"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from "../"
|
||||
import { withRetry } from "../retry"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { ToolCallProcessor } from "../transform/tool-call-processor"
|
||||
|
||||
interface BasetenHandlerOptions extends CommonApiHandlerOptions {
|
||||
basetenApiKey?: string
|
||||
basetenModelId?: string
|
||||
basetenModelInfo?: ModelInfo
|
||||
apiModelId?: string // For backward compatibility
|
||||
}
|
||||
|
||||
export class BasetenHandler implements ApiHandler {
|
||||
private options: BasetenHandlerOptions
|
||||
private client: OpenAI | undefined
|
||||
|
||||
constructor(options: BasetenHandlerOptions) {
|
||||
this.options = options
|
||||
}
|
||||
|
||||
private ensureClient(): OpenAI {
|
||||
if (!this.client) {
|
||||
if (!this.options.basetenApiKey) {
|
||||
throw new Error("Baseten API key is required")
|
||||
}
|
||||
try {
|
||||
this.client = new OpenAI({
|
||||
baseURL: "https://inference.baseten.co/v1",
|
||||
apiKey: this.options.basetenApiKey,
|
||||
defaultHeaders: buildExternalBasicHeaders(),
|
||||
fetch, // Use configured fetch with proxy support
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(`Error creating Baseten client: ${error.message}`)
|
||||
}
|
||||
}
|
||||
return this.client
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the optimal max_tokens based on model capabilities
|
||||
*/
|
||||
private getOptimalMaxTokens(model: { id: BasetenModelId; info: ModelInfo }): number {
|
||||
// Use model-specific max tokens if available
|
||||
if (model.info.maxTokens && model.info.maxTokens > 0) {
|
||||
return model.info.maxTokens
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return 8192
|
||||
}
|
||||
|
||||
getModel(): { id: BasetenModelId; info: ModelInfo } {
|
||||
// First priority: basetenModelId and basetenModelInfo
|
||||
const basetenModelId = this.options.basetenModelId
|
||||
const basetenModelInfo = this.options.basetenModelInfo
|
||||
if (basetenModelId && basetenModelInfo) {
|
||||
return { id: basetenModelId as BasetenModelId, info: basetenModelInfo }
|
||||
}
|
||||
|
||||
// Second priority: basetenModelId with static model info
|
||||
if (basetenModelId && basetenModelId in basetenModels) {
|
||||
const id = basetenModelId as BasetenModelId
|
||||
return { id, info: basetenModels[id] }
|
||||
}
|
||||
|
||||
// Third priority: apiModelId (for backward compatibility)
|
||||
const apiModelId = this.options.apiModelId
|
||||
if (apiModelId && apiModelId in basetenModels) {
|
||||
const id = apiModelId as BasetenModelId
|
||||
return { id, info: basetenModels[id] }
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return {
|
||||
id: basetenDefaultModelId,
|
||||
info: basetenModels[basetenDefaultModelId],
|
||||
}
|
||||
}
|
||||
|
||||
private async *yieldUsage(modelInfo: ModelInfo, usage: any): ApiStream {
|
||||
if (usage.prompt_tokens || usage.completion_tokens) {
|
||||
const cost = calculateApiCostOpenAI(modelInfo, usage.prompt_tokens || 0, usage.completion_tokens || 0)
|
||||
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: usage.prompt_tokens || 0,
|
||||
outputTokens: usage.completion_tokens || 0,
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
totalCost: cost,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: OpenAITool[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
const model = this.getModel()
|
||||
const maxTokens = this.getOptimalMaxTokens(model)
|
||||
const toolCallProcessor = new ToolCallProcessor()
|
||||
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...convertToOpenAiMessages(messages),
|
||||
]
|
||||
|
||||
const stream = await client.chat.completions.create({
|
||||
model: model.id,
|
||||
max_tokens: maxTokens,
|
||||
messages: openAiMessages,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
temperature: 0,
|
||||
tools,
|
||||
tool_choice: tools && tools.length > 0 ? "auto" : undefined,
|
||||
})
|
||||
|
||||
let didOutputUsage = false
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk?.choices?.[0]?.delta
|
||||
|
||||
// Handle reasoning field if present (for reasoning models with parsed output)
|
||||
if (delta && "reasoning" in delta && delta?.reasoning) {
|
||||
const reasoning = typeof delta.reasoning === "string" ? delta.reasoning : JSON.stringify(delta.reasoning)
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle content field
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
}
|
||||
}
|
||||
|
||||
if (delta?.tool_calls) {
|
||||
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
|
||||
}
|
||||
|
||||
// Handle usage information - only output once
|
||||
if (!didOutputUsage && chunk.usage) {
|
||||
yield* this.yieldUsage(model.info, chunk.usage)
|
||||
didOutputUsage = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current model supports tools
|
||||
*/
|
||||
supportsTools(): boolean {
|
||||
const model = this.getModel()
|
||||
const modelInfo = model.info as any
|
||||
|
||||
// Use dynamic API data when available, fallback to true since all current Baseten models support tools
|
||||
// (as of 2025-09-16 - could change if Baseten add non-tool models in future, currently no plans to do so)
|
||||
return modelInfo.supportedFeatures ? modelInfo.supportedFeatures.includes("tools") : true
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,275 +0,0 @@
|
||||
import Cerebras from "@cerebras/cerebras_cloud_sdk"
|
||||
import { CerebrasModelId, cerebrasDefaultModelId, cerebrasModels, ModelInfo } from "@shared/api"
|
||||
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { fetch } from "@/shared/net"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from "../index"
|
||||
import { withRetry } from "../retry"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
|
||||
interface CerebrasHandlerOptions extends CommonApiHandlerOptions {
|
||||
cerebrasApiKey?: string
|
||||
apiModelId?: string
|
||||
}
|
||||
|
||||
// Conservative max_tokens for Cerebras to avoid premature rate limiting.
|
||||
// Cerebras rate limiter estimates token consumption using max_completion_tokens upfront,
|
||||
// so requesting the model maximum (e.g., 64K) reserves that quota even if actual usage is low.
|
||||
// 16K is sufficient for most agentic tool use while preserving rate limit headroom.
|
||||
const CEREBRAS_DEFAULT_MAX_TOKENS = 16_384
|
||||
|
||||
export class CerebrasHandler implements ApiHandler {
|
||||
private options: CerebrasHandlerOptions
|
||||
private client: Cerebras | undefined
|
||||
|
||||
constructor(options: CerebrasHandlerOptions) {
|
||||
this.options = options
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
try {
|
||||
const externalHeaders = buildExternalBasicHeaders()
|
||||
this.client = new Cerebras({
|
||||
apiKey: cleanApiKey,
|
||||
timeout: 30000, // 30 second timeout
|
||||
fetch, // Use configured fetch with proxy support
|
||||
defaultHeaders: {
|
||||
...externalHeaders,
|
||||
"X-Cerebras-3rd-Party-Integration": "cline",
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(`Error creating Cerebras client: ${error.message}`)
|
||||
}
|
||||
}
|
||||
return this.client
|
||||
}
|
||||
|
||||
@withRetry({
|
||||
maxRetries: 6, // More retries to be patient with rate limits
|
||||
baseDelay: 5000, // Start with 5 second delay
|
||||
maxDelay: 60000, // Allow up to 60 second delays to respect rate limits
|
||||
})
|
||||
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[]): 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")
|
||||
|
||||
// Convert Anthropic messages to Cerebras format
|
||||
for (const message of messages) {
|
||||
if (message.role === "user") {
|
||||
const content = Array.isArray(message.content)
|
||||
? message.content
|
||||
.map((block) => {
|
||||
if (block.type === "text") {
|
||||
return block.text
|
||||
}
|
||||
if (block.type === "image") {
|
||||
return "[Image content not supported in Cerebras]"
|
||||
}
|
||||
return ""
|
||||
})
|
||||
.join("\n")
|
||||
: message.content
|
||||
cerebrasMessages.push({ role: "user", content })
|
||||
} else if (message.role === "assistant") {
|
||||
let content = Array.isArray(message.content)
|
||||
? message.content
|
||||
.map((block) => {
|
||||
if (block.type === "text") {
|
||||
return block.text
|
||||
}
|
||||
return ""
|
||||
})
|
||||
.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 model = this.getModel()
|
||||
const stream = await client.chat.completions.create({
|
||||
model: model.id,
|
||||
messages: cerebrasMessages,
|
||||
temperature: model.info.temperature ?? 0,
|
||||
stream: true,
|
||||
max_tokens: CEREBRAS_DEFAULT_MAX_TOKENS,
|
||||
})
|
||||
|
||||
// Handle streaming response
|
||||
let reasoning: string | null = null // Track reasoning content for models that support thinking
|
||||
|
||||
for await (const chunk of stream as any) {
|
||||
// Type assertion for the streaming chunk
|
||||
const streamChunk = chunk as any
|
||||
|
||||
if (streamChunk.choices?.[0]?.delta?.content) {
|
||||
const content = streamChunk.choices[0].delta.content
|
||||
|
||||
// Handle reasoning models (Qwen and DeepSeek R1 Distill) that use <think> tags
|
||||
if (isReasoningModel) {
|
||||
// Check if we're entering or continuing reasoning mode
|
||||
if (reasoning || content.includes("<think>")) {
|
||||
reasoning = (reasoning || "") + content
|
||||
|
||||
// Clean the content by removing think tags for display
|
||||
const cleanContent = content.replace(/<think>/g, "").replace(/<\/think>/g, "")
|
||||
|
||||
// Only yield reasoning content if there's actual content after cleaning
|
||||
if (cleanContent.trim()) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: cleanContent,
|
||||
}
|
||||
}
|
||||
|
||||
// Check if reasoning is complete
|
||||
if (reasoning.includes("</think>")) {
|
||||
reasoning = null
|
||||
}
|
||||
} else {
|
||||
// Regular content outside of thinking tags
|
||||
yield {
|
||||
type: "text",
|
||||
text: content,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Non-reasoning models - just yield text content
|
||||
yield {
|
||||
type: "text",
|
||||
text: content,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle usage information from Cerebras API
|
||||
// Usage is typically only available in the final chunk
|
||||
if (streamChunk.usage) {
|
||||
const totalCost = this.calculateCost({
|
||||
inputTokens: streamChunk.usage.prompt_tokens || 0,
|
||||
outputTokens: streamChunk.usage.completion_tokens || 0,
|
||||
})
|
||||
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: streamChunk.usage.prompt_tokens || 0,
|
||||
outputTokens: streamChunk.usage.completion_tokens || 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost,
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Enhanced error handling for Cerebras API
|
||||
if (error?.status === 429 || error?.code === "rate_limit_exceeded") {
|
||||
// Rate limit error - will be handled by retry decorator with patient backoff
|
||||
const _limits = this.getRateLimits()
|
||||
throw new Error(`Cerebras API rate limit exceeded.`)
|
||||
}
|
||||
if (error?.status === 401) {
|
||||
throw new Error("Cerebras API authentication failed. Please check your API key.")
|
||||
}
|
||||
if (error?.status === 403) {
|
||||
throw new Error("Cerebras API access denied. Please check your API key permissions.")
|
||||
}
|
||||
if (error?.status >= 500) {
|
||||
// Server errors - retryable
|
||||
throw new Error(`Cerebras API server error (${error.status}): ${error.message || "Unknown server error"}`)
|
||||
}
|
||||
if (error?.status === 400) {
|
||||
// Client errors - not retryable
|
||||
throw new Error(`Cerebras API bad request: ${error.message || "Invalid request parameters"}`)
|
||||
}
|
||||
|
||||
// Re-throw original error for other cases
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
getModel(): { id: string; info: ModelInfo } {
|
||||
const originalModelId = this.options.apiModelId
|
||||
let apiModelId = originalModelId
|
||||
if (originalModelId === "qwen-3-coder-480b-free") {
|
||||
apiModelId = "qwen-3-coder-480b"
|
||||
return { id: apiModelId, info: cerebrasModels[originalModelId as CerebrasModelId] }
|
||||
}
|
||||
|
||||
if (originalModelId && originalModelId in cerebrasModels) {
|
||||
const id = originalModelId as CerebrasModelId
|
||||
return { id, info: cerebrasModels[id] }
|
||||
}
|
||||
return {
|
||||
id: cerebrasDefaultModelId,
|
||||
info: cerebrasModels[cerebrasDefaultModelId],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rate limit information for the current model
|
||||
*
|
||||
* These limits are used for informational purposes and to calculate appropriate
|
||||
* retry delays. Since Cerebras inference is extremely fast, users hit these limits
|
||||
* quickly, so we need to be patient with retries to maximize usage efficiency.
|
||||
*
|
||||
* @returns Rate limit configuration for the model
|
||||
*/
|
||||
private getRateLimits(): { requestsPerMinute: number; tokensPerMinute: number } {
|
||||
const modelId = this.getModel().id
|
||||
|
||||
switch (modelId) {
|
||||
case "qwen-3-coder-480b":
|
||||
case "qwen-3-coder-480b-free":
|
||||
return { requestsPerMinute: 10, tokensPerMinute: 150_000 }
|
||||
case "qwen-3-235b-a22b-instruct-2507":
|
||||
case "qwen-3-235b-a22b-thinking-2507":
|
||||
return { requestsPerMinute: 30, tokensPerMinute: 60_000 }
|
||||
case "gpt-oss-120b":
|
||||
return { requestsPerMinute: 30, tokensPerMinute: 64_000 }
|
||||
default:
|
||||
// Default rate limits for unknown models
|
||||
return { requestsPerMinute: 30, tokensPerMinute: 60_000 }
|
||||
}
|
||||
}
|
||||
|
||||
private calculateCost({ inputTokens, outputTokens }: { inputTokens: number; outputTokens: number }): number {
|
||||
const model = this.getModel()
|
||||
const inputPrice = model.info.inputPrice || 0
|
||||
const outputPrice = model.info.outputPrice || 0
|
||||
|
||||
const inputCost = (inputPrice / 1_000_000) * inputTokens
|
||||
const outputCost = (outputPrice / 1_000_000) * outputTokens
|
||||
|
||||
return inputCost + outputCost
|
||||
}
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
import { filterMessagesForClaudeCode } from "@/integrations/claude-code/message-filter"
|
||||
import { runClaudeCode } from "@/integrations/claude-code/run"
|
||||
import { ClaudeCodeModelId, claudeCodeDefaultModelId, claudeCodeModels } from "@/shared/api"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { type ApiHandler, CommonApiHandlerOptions } from ".."
|
||||
import { withRetry } from "../retry"
|
||||
import { type ApiStream, ApiStreamUsageChunk } from "../transform/stream"
|
||||
|
||||
interface ClaudeCodeHandlerOptions extends CommonApiHandlerOptions {
|
||||
claudeCodePath?: string
|
||||
apiModelId?: string
|
||||
thinkingBudgetTokens?: number
|
||||
}
|
||||
|
||||
export class ClaudeCodeHandler implements ApiHandler {
|
||||
private options: ClaudeCodeHandlerOptions
|
||||
|
||||
constructor(options: ClaudeCodeHandlerOptions) {
|
||||
this.options = options
|
||||
}
|
||||
|
||||
@withRetry({
|
||||
maxRetries: 4,
|
||||
baseDelay: 2000,
|
||||
maxDelay: 15000,
|
||||
})
|
||||
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[]): ApiStream {
|
||||
// Filter out image blocks since Claude Code doesn't support them
|
||||
const filteredMessages = filterMessagesForClaudeCode(messages)
|
||||
|
||||
const claudeProcess = runClaudeCode({
|
||||
systemPrompt,
|
||||
messages: filteredMessages,
|
||||
path: this.options.claudeCodePath,
|
||||
modelId: this.getModel().id,
|
||||
thinkingBudgetTokens: this.options.thinkingBudgetTokens,
|
||||
})
|
||||
|
||||
// Usage is included with assistant messages,
|
||||
// but cost is included in the result chunk
|
||||
const usage: ApiStreamUsageChunk = {
|
||||
type: "usage",
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
}
|
||||
|
||||
let isPaidUsage = true
|
||||
|
||||
for await (const chunk of claudeProcess) {
|
||||
if (typeof chunk === "string") {
|
||||
yield {
|
||||
type: "text",
|
||||
text: chunk,
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle system init messages
|
||||
if (chunk.type === "system" && "subtype" in chunk) {
|
||||
if (chunk.subtype === "init") {
|
||||
// Based on my tests, subscription usage sets the `apiKeySource` to "none"
|
||||
isPaidUsage = (chunk as any).apiKeySource !== "none"
|
||||
}
|
||||
// Also handles legacy rate_limit_event format (type: "system", subtype: "rate_limit_event")
|
||||
// by falling through — no special handling needed.
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle rate_limit_event (newer CLI format: top-level type)
|
||||
if (chunk.type === "rate_limit_event") {
|
||||
// Rate limit events are informational. Log them but don't yield anything.
|
||||
// If the rate limit blocks the response, the stream will end without
|
||||
// assistant messages and the task loop will handle the empty response.
|
||||
Logger.log("Claude Code rate limit event:", JSON.stringify(chunk))
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip user messages (tool results from Claude Code's own tool execution)
|
||||
if (chunk.type === "user") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (chunk.type === "assistant" && "message" in chunk) {
|
||||
const message = chunk.message
|
||||
|
||||
// Check for error field on the message (newer CLI format)
|
||||
if (message.error) {
|
||||
const firstContent = message.content?.[0]
|
||||
const errorText = firstContent && "text" in firstContent ? firstContent.text : undefined
|
||||
throw new Error(errorText ?? `Claude Code error: ${message.error}`)
|
||||
}
|
||||
|
||||
if (message.stop_reason !== null) {
|
||||
const firstContent = message.content?.[0]
|
||||
const content = firstContent && "text" in firstContent ? firstContent : undefined
|
||||
|
||||
// Check if content exists before accessing its properties
|
||||
if (content && content.text.startsWith(`API Error`)) {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
for (const content of message.content) {
|
||||
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":
|
||||
// Yield tool_use blocks to the streaming pipeline for proper tool execution
|
||||
yield {
|
||||
type: "tool_calls",
|
||||
tool_call: {
|
||||
call_id: content.id,
|
||||
function: {
|
||||
id: content.id,
|
||||
name: content.name,
|
||||
arguments: JSON.stringify(content.input),
|
||||
},
|
||||
},
|
||||
}
|
||||
break
|
||||
default: {
|
||||
// Handle unknown content block types gracefully.
|
||||
// Newer Anthropic models or CLI versions may introduce new content types
|
||||
// (e.g., server_tool_use, mcp_tool_use). Log them instead of silently dropping.
|
||||
const unknownBlock = content as { type: string; text?: string }
|
||||
Logger.warn(`Unhandled content type in Claude Code response: ${unknownBlock.type}`)
|
||||
// If the unknown block has a text-like field, try to yield it as text
|
||||
if (typeof unknownBlock.text === "string") {
|
||||
yield {
|
||||
type: "text",
|
||||
text: unknownBlock.text,
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// According to Anthropic's API documentation:
|
||||
// https://docs.anthropic.com/en/api/messages#usage-object
|
||||
// The `input_tokens` field already includes both `cache_read_input_tokens` and `cache_creation_input_tokens`.
|
||||
// Therefore, we should not add cache tokens to the input_tokens count again, as this would result in double-counting.
|
||||
usage.inputTokens = message.usage?.input_tokens ?? 0
|
||||
usage.outputTokens = message.usage?.output_tokens ?? 0
|
||||
usage.cacheReadTokens = message.usage?.cache_read_input_tokens ?? 0
|
||||
usage.cacheWriteTokens = message.usage?.cache_creation_input_tokens ?? 0
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (chunk.type === "result" && "result" in chunk) {
|
||||
if (chunk.is_error) {
|
||||
throw new Error(`Claude Code returned an error: ${chunk.result}`)
|
||||
}
|
||||
|
||||
usage.totalCost = isPaidUsage ? chunk.total_cost_usd : 0
|
||||
|
||||
yield usage
|
||||
continue
|
||||
}
|
||||
|
||||
// ErrorMessage — log it explicitly and skip
|
||||
if ((chunk as any).type === "error") {
|
||||
Logger.warn("Claude Code emitted an error-type chunk:", JSON.stringify(chunk))
|
||||
continue
|
||||
}
|
||||
|
||||
// Any completely unrecognized chunk type — log and skip
|
||||
Logger.warn(`Unrecognized Claude Code chunk type: ${(chunk as any).type}`)
|
||||
}
|
||||
}
|
||||
|
||||
private attemptParse(str: string) {
|
||||
try {
|
||||
return JSON.parse(str)
|
||||
} catch (_err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
getModel() {
|
||||
const modelId = this.options.apiModelId
|
||||
if (modelId && modelId in claudeCodeModels) {
|
||||
const id = modelId as ClaudeCodeModelId
|
||||
return { id, info: claudeCodeModels[id] }
|
||||
}
|
||||
|
||||
return {
|
||||
id: claudeCodeDefaultModelId,
|
||||
info: claudeCodeModels[claudeCodeDefaultModelId],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
import { clinePassModels, type ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "@shared/api"
|
||||
import { shouldSkipReasoningForModel } from "@utils/model-utils"
|
||||
import axios from "axios"
|
||||
import OpenAI from "openai"
|
||||
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
|
||||
import { ClineEnv } from "@/config"
|
||||
import { refreshClineRecommendedModels } from "@/core/controller/models/refreshClineRecommendedModels"
|
||||
import { ClineAccountService } from "@/services/account/ClineAccountService"
|
||||
import { AuthService } from "@/services/auth/AuthService"
|
||||
import { buildClineExtraHeaders } from "@/services/EnvUtils"
|
||||
import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "@/shared/ClineAccount"
|
||||
import { CLINE_RECOMMENDED_MODELS_FALLBACK } from "@/shared/cline/recommended-models"
|
||||
import type { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { fetch, getAxiosSettings } from "@/shared/net"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { ApiHandler, CommonApiHandlerOptions } from "../"
|
||||
import { withRetry } from "../retry"
|
||||
import { createOpenRouterStream } from "../transform/openrouter-stream"
|
||||
import type { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
|
||||
import { ToolCallProcessor } from "../transform/tool-call-processor"
|
||||
import type { OpenRouterErrorResponse } from "./types"
|
||||
|
||||
interface ClineHandlerOptions extends CommonApiHandlerOptions {
|
||||
ulid?: string
|
||||
taskId?: string
|
||||
reasoningEffort?: string
|
||||
thinkingBudgetTokens?: number
|
||||
openRouterProviderSorting?: string
|
||||
openRouterModelId?: string
|
||||
openRouterModelInfo?: ModelInfo
|
||||
clineAccountId?: string
|
||||
clineApiKey?: string
|
||||
enableParallelToolCalling?: boolean
|
||||
}
|
||||
|
||||
function normalizeModelId(modelId: string): string {
|
||||
return modelId.trim().toLowerCase()
|
||||
}
|
||||
|
||||
const CLINE_FREE_MODEL_IDS = new Set([
|
||||
...CLINE_RECOMMENDED_MODELS_FALLBACK.free.map((model) => normalizeModelId(model.id)),
|
||||
...Object.keys(clinePassModels).map((modelId) => normalizeModelId(modelId)),
|
||||
])
|
||||
|
||||
function getCacheReadTokens(usage: any): number {
|
||||
return usage?.prompt_tokens_details?.cached_tokens || usage?.cache_read_input_tokens || 0
|
||||
}
|
||||
|
||||
function getCacheWriteTokens(usage: any): number {
|
||||
return usage?.prompt_tokens_details?.cache_write_tokens || usage?.cache_creation_input_tokens || 0
|
||||
}
|
||||
|
||||
export class ClineHandler implements ApiHandler {
|
||||
private options: ClineHandlerOptions
|
||||
private clineAccountService = ClineAccountService.getInstance()
|
||||
private _authService: AuthService
|
||||
private client: OpenAI | undefined
|
||||
lastGenerationId?: string
|
||||
private lastRequestId?: string
|
||||
|
||||
private get _baseUrl(): string {
|
||||
return ClineEnv.config().apiBaseUrl
|
||||
}
|
||||
|
||||
constructor(options: ClineHandlerOptions) {
|
||||
this.options = options
|
||||
this._authService = AuthService.getInstance()
|
||||
}
|
||||
|
||||
private async getFreeModelIdSet(): Promise<Set<string>> {
|
||||
try {
|
||||
const models = await refreshClineRecommendedModels()
|
||||
const freeModelIds = [...models.free, ...models.clinePass]
|
||||
.map((model) => normalizeModelId(model.id))
|
||||
.filter((modelId) => modelId.length > 0)
|
||||
if (freeModelIds.length > 0) {
|
||||
return new Set(freeModelIds)
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error("Error resolving Cline free model IDs from recommended models:", error)
|
||||
}
|
||||
|
||||
return CLINE_FREE_MODEL_IDS
|
||||
}
|
||||
|
||||
private async ensureClient(): Promise<OpenAI> {
|
||||
const clineAccountAuthToken = this.options.clineApiKey || (await this._authService.getAuthToken())
|
||||
if (!clineAccountAuthToken) {
|
||||
throw new Error(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE)
|
||||
}
|
||||
if (!this.client) {
|
||||
try {
|
||||
const defaultHeaders: Record<string, string> = {
|
||||
"HTTP-Referer": "https://cline.bot",
|
||||
"X-Title": "Cline",
|
||||
"X-Task-ID": this.options.ulid || "",
|
||||
}
|
||||
Object.assign(defaultHeaders, await buildClineExtraHeaders())
|
||||
|
||||
this.client = new OpenAI({
|
||||
baseURL: `${this._baseUrl}/api/v1`,
|
||||
apiKey: clineAccountAuthToken,
|
||||
defaultHeaders,
|
||||
// Capture real HTTP request ID from initial streaming response headers
|
||||
fetch: async (...args: Parameters<typeof fetch>): Promise<Awaited<ReturnType<typeof fetch>>> => {
|
||||
const [input, init] = args
|
||||
const resp = await fetch(input, init)
|
||||
try {
|
||||
let urlStr = ""
|
||||
if (typeof input === "string") {
|
||||
urlStr = input
|
||||
} else if (input instanceof URL) {
|
||||
urlStr = input.toString()
|
||||
} else if (typeof (input as { url?: unknown }).url === "string") {
|
||||
urlStr = (input as { url: string }).url
|
||||
}
|
||||
// Only record for chat completions (the primary streaming request)
|
||||
if (urlStr.includes("/chat/completions")) {
|
||||
const rid = resp.headers.get("x-request-id") || resp.headers.get("request-id")
|
||||
if (rid) {
|
||||
this.lastRequestId = rid
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore header capture errors
|
||||
}
|
||||
return resp
|
||||
},
|
||||
})
|
||||
} catch (error: any) {
|
||||
throw new Error(`Error creating Cline client: ${error.message}`)
|
||||
}
|
||||
}
|
||||
// Ensure the client is always using the latest auth token
|
||||
this.client.apiKey = clineAccountAuthToken
|
||||
return this.client
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: OpenAITool[]): ApiStream {
|
||||
try {
|
||||
const client = await this.ensureClient()
|
||||
|
||||
this.lastGenerationId = undefined
|
||||
this.lastRequestId = undefined
|
||||
|
||||
let didOutputUsage = false
|
||||
const freeModelIds = await this.getFreeModelIdSet()
|
||||
|
||||
const stream = await createOpenRouterStream(
|
||||
client,
|
||||
systemPrompt,
|
||||
messages,
|
||||
this.getModel(),
|
||||
this.options.reasoningEffort,
|
||||
this.options.thinkingBudgetTokens,
|
||||
this.options.openRouterProviderSorting,
|
||||
tools,
|
||||
this.options.enableParallelToolCalling,
|
||||
)
|
||||
|
||||
const toolCallProcessor = new ToolCallProcessor()
|
||||
|
||||
for await (const chunk of stream) {
|
||||
Logger.debug("ClineHandler chunk:" + JSON.stringify(chunk))
|
||||
// openrouter returns an error object instead of the openai sdk throwing an error
|
||||
if ("error" in chunk) {
|
||||
const error = chunk.error as OpenRouterErrorResponse["error"]
|
||||
Logger.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
|
||||
Logger.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}`)
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
if (delta?.tool_calls) {
|
||||
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
|
||||
}
|
||||
|
||||
// Reasoning tokens are returned separately from the content
|
||||
// Skip reasoning content for Grok 4 models since it only displays "thinking" without providing useful information
|
||||
if (
|
||||
delta &&
|
||||
"reasoning" in delta &&
|
||||
delta.reasoning &&
|
||||
!shouldSkipReasoningForModel(this.options.openRouterModelId)
|
||||
) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: typeof delta.reasoning === "string" ? delta.reasoning : JSON.stringify(delta.reasoning),
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
OpenRouter passes reasoning details that we can pass back unmodified in api requests to preserve reasoning traces for model
|
||||
- The reasoning_details array in each chunk may contain one or more reasoning objects
|
||||
- For encrypted reasoning, the content may appear as [REDACTED] in streaming responses
|
||||
- The complete reasoning sequence is built by concatenating all chunks in order
|
||||
See: https://openrouter.ai/docs/use-cases/reasoning-tokens#preserving-reasoning-blocks
|
||||
*/
|
||||
if (
|
||||
delta &&
|
||||
"reasoning_details" in delta &&
|
||||
delta.reasoning_details &&
|
||||
// @ts-expect-error-next-line
|
||||
delta?.reasoning_details?.length && // exists and non-0
|
||||
!shouldSkipReasoningForModel(this.options.openRouterModelId)
|
||||
) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: "",
|
||||
details: delta.reasoning_details,
|
||||
}
|
||||
}
|
||||
|
||||
if (!didOutputUsage && chunk.usage) {
|
||||
// @ts-expect-error-next-line
|
||||
let totalCost = (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0)
|
||||
const modelId = this.getModel().id
|
||||
const isFreeModel = freeModelIds.has(normalizeModelId(modelId))
|
||||
const cacheReadTokens = getCacheReadTokens(chunk.usage)
|
||||
const cacheWriteTokens = getCacheWriteTokens(chunk.usage)
|
||||
|
||||
if (isFreeModel) {
|
||||
totalCost = 0
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "usage",
|
||||
cacheWriteTokens,
|
||||
cacheReadTokens,
|
||||
inputTokens: Math.max(0, (chunk.usage.prompt_tokens || 0) - cacheReadTokens - cacheWriteTokens),
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
totalCost,
|
||||
}
|
||||
didOutputUsage = true
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to generation endpoint if usage chunk not returned
|
||||
if (!didOutputUsage) {
|
||||
Logger.warn("Cline API did not return usage chunk, fetching from generation endpoint")
|
||||
const apiStreamUsage = await this.getApiStreamUsage(freeModelIds)
|
||||
if (apiStreamUsage) {
|
||||
yield apiStreamUsage
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error("Cline API Error:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async getApiStreamUsage(freeModelIds?: Set<string>): Promise<ApiStreamUsageChunk | undefined> {
|
||||
if (this.lastGenerationId) {
|
||||
try {
|
||||
const resolvedFreeModelIds = freeModelIds || (await this.getFreeModelIdSet())
|
||||
const clineAccountAuthToken = await this._authService.getAuthToken()
|
||||
if (!clineAccountAuthToken) {
|
||||
throw new Error(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE)
|
||||
}
|
||||
const headers: Record<string, string> = {
|
||||
// Align with backend auth expectations
|
||||
Authorization: `Bearer ${clineAccountAuthToken}`,
|
||||
}
|
||||
Object.assign(headers, await buildClineExtraHeaders())
|
||||
|
||||
const response = await axios.get(`${this.clineAccountService.baseUrl}/generation?id=${this.lastGenerationId}`, {
|
||||
headers,
|
||||
timeout: 15_000, // this request hangs sometimes
|
||||
...getAxiosSettings(),
|
||||
})
|
||||
|
||||
const generation = response.data
|
||||
let totalCost = generation?.total_cost || 0
|
||||
const modelId = this.getModel().id
|
||||
const isFreeModel = resolvedFreeModelIds.has(normalizeModelId(modelId))
|
||||
|
||||
if (isFreeModel) {
|
||||
totalCost = 0
|
||||
}
|
||||
|
||||
return {
|
||||
type: "usage",
|
||||
cacheWriteTokens: generation?.native_tokens_cache_write || 0,
|
||||
cacheReadTokens: generation?.native_tokens_cached || 0,
|
||||
// openrouter generation endpoint fails often
|
||||
inputTokens: Math.max(
|
||||
0,
|
||||
(generation?.native_tokens_prompt || 0) -
|
||||
(generation?.native_tokens_cached || 0) -
|
||||
(generation?.native_tokens_cache_write || 0),
|
||||
),
|
||||
outputTokens: generation?.native_tokens_completion || 0,
|
||||
totalCost,
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore if fails
|
||||
Logger.error("Error fetching cline generation details:", error)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Expose the last HTTP request ID captured from response headers (X-Request-ID)
|
||||
getLastRequestId(): string | undefined {
|
||||
return this.lastRequestId
|
||||
}
|
||||
|
||||
getModel(): { id: string; info: ModelInfo } {
|
||||
const modelId = this.options.openRouterModelId
|
||||
const modelInfo = this.options.openRouterModelInfo
|
||||
if (modelId && modelInfo) {
|
||||
return { id: modelId, info: modelInfo }
|
||||
}
|
||||
// If we have a model ID but no model info (e.g., CLI featured models),
|
||||
// use the ID with default model info rather than falling back to a different model
|
||||
if (modelId) {
|
||||
return { id: modelId, info: openRouterDefaultModelInfo }
|
||||
}
|
||||
return { id: openRouterDefaultModelId, info: openRouterDefaultModelInfo }
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import { DeepSeekModelId, deepSeekDefaultModelId, deepSeekModels, ModelInfo } from "@shared/api"
|
||||
import { calculateApiCostOpenAI } from "@utils/cost"
|
||||
import OpenAI from "openai"
|
||||
import type {
|
||||
ChatCompletionReasoningEffort,
|
||||
ChatCompletionTool as OpenAITool,
|
||||
} from "openai/resources/chat/completions"
|
||||
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { fetch } from "@/shared/net"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from "../"
|
||||
import { withRetry } from "../retry"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { addReasoningContent } from "../transform/r1-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
|
||||
|
||||
interface DeepSeekHandlerOptions extends CommonApiHandlerOptions {
|
||||
deepSeekApiKey?: string
|
||||
apiModelId?: string
|
||||
reasoningEffort?: string
|
||||
}
|
||||
|
||||
export class DeepSeekHandler implements ApiHandler {
|
||||
private options: DeepSeekHandlerOptions
|
||||
private client: OpenAI | undefined
|
||||
|
||||
constructor(options: DeepSeekHandlerOptions) {
|
||||
this.options = options
|
||||
}
|
||||
|
||||
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,
|
||||
defaultHeaders: buildExternalBasicHeaders(),
|
||||
fetch, // Use configured fetch with proxy support
|
||||
})
|
||||
} 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 {
|
||||
// Deepseek reports total input AND cache reads/writes,
|
||||
// see context caching: https://api-docs.deepseek.com/guides/kv_cache)
|
||||
// where the input tokens is the sum of the cache hits/misses, just like OpenAI.
|
||||
// This affects:
|
||||
// 1) context management truncation algorithm, and
|
||||
// 2) cost calculation
|
||||
|
||||
// Deepseek usage includes extra fields.
|
||||
// Safely cast the prompt token details section to the appropriate structure.
|
||||
interface DeepSeekUsage extends OpenAI.CompletionUsage {
|
||||
prompt_cache_hit_tokens?: number
|
||||
prompt_cache_miss_tokens?: number
|
||||
}
|
||||
const deepUsage = usage as DeepSeekUsage
|
||||
|
||||
const inputTokens = deepUsage?.prompt_tokens || 0 // sum of cache hits and misses
|
||||
const outputTokens = deepUsage?.completion_tokens || 0
|
||||
const cacheReadTokens = deepUsage?.prompt_cache_hit_tokens || 0
|
||||
const cacheWriteTokens = deepUsage?.prompt_cache_miss_tokens || 0
|
||||
const totalCost = calculateApiCostOpenAI(info, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens)
|
||||
const nonCachedInputTokens = Math.max(0, inputTokens - cacheReadTokens - cacheWriteTokens) // this will always be 0
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: nonCachedInputTokens,
|
||||
outputTokens: outputTokens,
|
||||
cacheWriteTokens: cacheWriteTokens,
|
||||
cacheReadTokens: cacheReadTokens,
|
||||
totalCost: totalCost,
|
||||
}
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: OpenAITool[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
const model = this.getModel()
|
||||
|
||||
const isDeepSeekReasonerModel = model.id.includes("deepseek-reasoner")
|
||||
const isDeepSeekThinkingModel =
|
||||
isDeepSeekReasonerModel || model.id === "deepseek-v4-flash" || model.id === "deepseek-v4-pro"
|
||||
|
||||
const convertedMessages = convertToOpenAiMessages(messages)
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = isDeepSeekReasonerModel
|
||||
? [{ role: "system", content: systemPrompt }, ...addReasoningContent(convertedMessages, messages)]
|
||||
: [{ role: "system", content: systemPrompt }, ...convertedMessages]
|
||||
|
||||
const stream = await client.chat.completions.create({
|
||||
model: model.id,
|
||||
max_completion_tokens: model.info.maxTokens,
|
||||
messages: openAiMessages,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
// Only set temperature for non-thinking models
|
||||
...(isDeepSeekThinkingModel ? {} : { temperature: 0 }),
|
||||
// DeepSeek thinking models accept reasoning effort (low/medium map to high, xhigh maps to max).
|
||||
// "none" isn't a valid DeepSeek value, so omit it and let the API use its default.
|
||||
...(isDeepSeekThinkingModel && this.options.reasoningEffort && this.options.reasoningEffort !== "none"
|
||||
? { reasoning_effort: this.options.reasoningEffort as ChatCompletionReasoningEffort }
|
||||
: {}),
|
||||
...getOpenAIToolParams(tools),
|
||||
})
|
||||
|
||||
const toolCallProcessor = new ToolCallProcessor()
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices?.[0]?.delta
|
||||
if (delta && "reasoning_content" in delta && delta.reasoning_content) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: (delta.reasoning_content as string | undefined) || "",
|
||||
}
|
||||
}
|
||||
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
}
|
||||
}
|
||||
|
||||
if (delta?.tool_calls) {
|
||||
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
yield* this.yieldUsage(model.info, chunk.usage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getModel(): { id: DeepSeekModelId; info: ModelInfo } {
|
||||
const modelId = this.options.apiModelId
|
||||
if (modelId && modelId in deepSeekModels) {
|
||||
const id = modelId as DeepSeekModelId
|
||||
return { id, info: deepSeekModels[id] }
|
||||
}
|
||||
return {
|
||||
id: deepSeekDefaultModelId,
|
||||
info: deepSeekModels[deepSeekDefaultModelId],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,657 +0,0 @@
|
||||
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { fetch } from "@/shared/net"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { ModelInfo } from "../../../shared/api"
|
||||
import { ApiHandler } from "../index"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
|
||||
interface DifyHandlerOptions {
|
||||
difyApiKey?: string
|
||||
difyBaseUrl?: string
|
||||
}
|
||||
|
||||
// Dify API Response Types
|
||||
export interface DifyFileResponse {
|
||||
id: string
|
||||
name: string
|
||||
size: number
|
||||
extension: string
|
||||
mime_type: string
|
||||
created_by: string
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export interface DifyMessage {
|
||||
id: string
|
||||
conversation_id: string
|
||||
inputs: Record<string, any>
|
||||
query: string
|
||||
message_files: Array<{
|
||||
id: string
|
||||
type: string
|
||||
url: string
|
||||
belongs_to: string
|
||||
}>
|
||||
answer: string
|
||||
created_at: number
|
||||
feedback?: {
|
||||
rating: string
|
||||
}
|
||||
retriever_resources?: any[]
|
||||
}
|
||||
|
||||
interface DifyHistoryResponse {
|
||||
data: DifyMessage[]
|
||||
has_more: boolean
|
||||
limit: number
|
||||
}
|
||||
|
||||
interface DifyConversation {
|
||||
id: string
|
||||
name: string
|
||||
inputs: Record<string, any>
|
||||
status: string
|
||||
introduction: string
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
interface DifyConversationsResponse {
|
||||
data: DifyConversation[]
|
||||
has_more: boolean
|
||||
limit: number
|
||||
}
|
||||
|
||||
interface DifyConversationResponse {
|
||||
id: string
|
||||
name: string
|
||||
inputs: Record<string, any>
|
||||
status: string
|
||||
introduction: string
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export class DifyHandler implements ApiHandler {
|
||||
private options: DifyHandlerOptions
|
||||
private baseUrl: string
|
||||
private apiKey: string
|
||||
private conversationId: string | null = null
|
||||
private currentTaskId: string | null = null
|
||||
private abortController: AbortController | null = null
|
||||
|
||||
constructor(options: DifyHandlerOptions) {
|
||||
this.options = options
|
||||
this.apiKey = options.difyApiKey || ""
|
||||
this.baseUrl = options.difyBaseUrl || ""
|
||||
|
||||
Logger.log("[DIFY DEBUG] Constructor called with:", {
|
||||
hasApiKey: !!this.apiKey,
|
||||
baseUrl: this.baseUrl,
|
||||
})
|
||||
|
||||
if (!this.apiKey) {
|
||||
throw new Error("Dify API key is required")
|
||||
}
|
||||
if (!this.baseUrl) {
|
||||
throw new Error("Dify base URL is required")
|
||||
}
|
||||
}
|
||||
|
||||
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[]): ApiStream {
|
||||
Logger.log("[DIFY DEBUG] createMessage called with:", {
|
||||
systemPromptLength: systemPrompt?.length || 0,
|
||||
messagesCount: messages?.length || 0,
|
||||
})
|
||||
|
||||
// Convert messages to Dify format
|
||||
const query = this.convertMessagesToQuery(systemPrompt, messages)
|
||||
const requestBody = {
|
||||
inputs: {},
|
||||
query: query,
|
||||
response_mode: "streaming",
|
||||
conversation_id: this.conversationId || "",
|
||||
user: "cline-user", // A unique user identifier
|
||||
files: [],
|
||||
}
|
||||
|
||||
const fullUrl = `${this.baseUrl}/chat-messages`
|
||||
Logger.log("[DIFY DEBUG] Making request to:", fullUrl)
|
||||
Logger.log("[DIFY DEBUG] Request body:", JSON.stringify(requestBody, null, 2))
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(fullUrl, {
|
||||
method: "POST",
|
||||
headers: this.jsonHeaders(),
|
||||
body: JSON.stringify(requestBody),
|
||||
})
|
||||
} catch (error: any) {
|
||||
Logger.error("[DIFY DEBUG] Network error during fetch:", error)
|
||||
const cause = error.cause ? ` | Cause: ${error.cause}` : ""
|
||||
throw new Error(`Dify API network error: ${error.message}${cause}`)
|
||||
}
|
||||
|
||||
Logger.log("[DIFY DEBUG] Response status:", response.status)
|
||||
const headersObj: Record<string, string> = {}
|
||||
response.headers.forEach((value, key) => {
|
||||
headersObj[key] = value
|
||||
})
|
||||
Logger.log("[DIFY DEBUG] Response headers:", headersObj)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
Logger.error("[DIFY DEBUG] Error response:", errorText)
|
||||
throw new Error(`Dify API error: ${response.status} ${response.statusText} - ${errorText}`)
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("No response body from Dify API")
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
let fullText = ""
|
||||
let hasYieldedContent = false
|
||||
const processedEvents: string[] = []
|
||||
let lastEventTime = Date.now()
|
||||
|
||||
Logger.log("[DIFY DEBUG] Starting to read streaming response...")
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
Logger.log("[DIFY DEBUG] Stream ended naturally")
|
||||
Logger.log(
|
||||
"[DIFY DEBUG] Final state - hasYieldedContent:",
|
||||
hasYieldedContent,
|
||||
"fullText length:",
|
||||
fullText.length,
|
||||
"processedEvents:",
|
||||
processedEvents,
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
Logger.log("[DIFY DEBUG] Raw chunk received:", JSON.stringify(chunk))
|
||||
|
||||
buffer += chunk
|
||||
const lines = buffer.split("\n")
|
||||
|
||||
// Keep the last incomplete line in the buffer
|
||||
buffer = lines.pop() || ""
|
||||
|
||||
for (const line of lines) {
|
||||
Logger.log("[DIFY DEBUG] Processing line:", JSON.stringify(line))
|
||||
|
||||
if (line.startsWith("data: ")) {
|
||||
const data = line.slice(6).trim()
|
||||
Logger.log("[DIFY DEBUG] Extracted data:", JSON.stringify(data))
|
||||
|
||||
if (data === "[DONE]") {
|
||||
Logger.log("[DIFY DEBUG] Received [DONE] signal")
|
||||
break
|
||||
}
|
||||
|
||||
if (data === "") {
|
||||
Logger.log("[DIFY DEBUG] Empty data line, skipping")
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
Logger.log("[DIFY DEBUG] Parsed JSON:", parsed)
|
||||
processedEvents.push(parsed.event || "unknown")
|
||||
lastEventTime = Date.now()
|
||||
|
||||
// Capture conversation_id as soon as it's available
|
||||
if (parsed.conversation_id && !this.conversationId) {
|
||||
this.conversationId = parsed.conversation_id
|
||||
Logger.log("[DIFY DEBUG] Captured conversation_id:", this.conversationId)
|
||||
}
|
||||
|
||||
// Handle different Dify event types based on actual Dify API
|
||||
if (parsed.event === "message") {
|
||||
Logger.log("[DIFY DEBUG] Message event, answer:", parsed.answer)
|
||||
// Dify sends the full text in each "answer" chunk, so we replace.
|
||||
if (typeof parsed.answer === "string") {
|
||||
fullText = parsed.answer
|
||||
Logger.log("[DIFY DEBUG] Updated fullText length:", fullText.length)
|
||||
yield {
|
||||
type: "text",
|
||||
text: fullText,
|
||||
}
|
||||
hasYieldedContent = true
|
||||
}
|
||||
} else if (parsed.event === "message_replace") {
|
||||
Logger.log("[DIFY DEBUG] Replace message event:", parsed)
|
||||
if (parsed.answer) {
|
||||
fullText = parsed.answer // Replace instead of append
|
||||
Logger.log("[DIFY DEBUG] Replaced fullText length:", fullText.length)
|
||||
yield {
|
||||
type: "text",
|
||||
text: fullText,
|
||||
}
|
||||
hasYieldedContent = true
|
||||
}
|
||||
} else if (parsed.event === "message_end") {
|
||||
Logger.log("[DIFY DEBUG] Message end event", parsed)
|
||||
// Message completed. Yield final text if we have any.
|
||||
if (fullText) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: fullText,
|
||||
}
|
||||
hasYieldedContent = true
|
||||
}
|
||||
// Yield usage data if available
|
||||
if (parsed.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: parsed.usage.prompt_tokens || 0,
|
||||
outputTokens: parsed.usage.completion_tokens || parsed.usage.total_tokens || 0,
|
||||
totalCost: parsed.usage.total_price || 0,
|
||||
}
|
||||
}
|
||||
return // End of stream
|
||||
} else if (parsed.event === "error") {
|
||||
Logger.error("[DIFY DEBUG] Error event:", parsed)
|
||||
throw new Error(`Dify API error: ${parsed.message || "Unknown error"}`)
|
||||
} else if (parsed.event === "workflow_started" || parsed.event === "workflow_finished") {
|
||||
Logger.log("[DIFY DEBUG] Workflow event:", parsed.event)
|
||||
// These are informational events, continue processing
|
||||
} else if (parsed.event === "node_started" || parsed.event === "node_finished") {
|
||||
Logger.log("[DIFY DEBUG] Node event:", parsed.event, parsed.data)
|
||||
// These are informational events, continue processing
|
||||
} else if (parsed.event === "ping") {
|
||||
Logger.log("[DIFY DEBUG] Ping event received, keeping connection alive.")
|
||||
// Ping event, do nothing
|
||||
} else {
|
||||
Logger.log("[DIFY DEBUG] Unknown event type:", parsed.event, "Full object:", parsed)
|
||||
// Try to extract text from other possible fields
|
||||
if (parsed.text) {
|
||||
fullText += parsed.text
|
||||
yield {
|
||||
type: "text",
|
||||
text: fullText,
|
||||
}
|
||||
hasYieldedContent = true
|
||||
} else if (parsed.content) {
|
||||
fullText += parsed.content
|
||||
yield {
|
||||
type: "text",
|
||||
text: fullText,
|
||||
}
|
||||
hasYieldedContent = true
|
||||
} else if (parsed.answer) {
|
||||
// Fallback: some events might have answer field even if not "message" type
|
||||
fullText += parsed.answer
|
||||
yield {
|
||||
type: "text",
|
||||
text: fullText,
|
||||
}
|
||||
hasYieldedContent = true
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.warn("[DIFY DEBUG] Failed to parse JSON:", data, "Error:", e)
|
||||
}
|
||||
} else if (line.trim() !== "") {
|
||||
Logger.log(
|
||||
"[DIFY DEBUG] Non-data line (not starting with 'data:'), trying to parse as direct JSON:",
|
||||
JSON.stringify(line),
|
||||
)
|
||||
// Try to parse as direct JSON (fallback for non-SSE responses, though Dify uses SSE)
|
||||
try {
|
||||
const parsed = JSON.parse(line.trim())
|
||||
Logger.log("[DIFY DEBUG] Parsed direct JSON:", parsed)
|
||||
processedEvents.push(parsed.event || "direct-json")
|
||||
|
||||
// Handle the same event types as above
|
||||
if (parsed.event === "message" && parsed.answer) {
|
||||
fullText += parsed.answer
|
||||
yield {
|
||||
type: "text",
|
||||
text: fullText,
|
||||
}
|
||||
hasYieldedContent = true
|
||||
} else if (parsed.event === "message_end") {
|
||||
if (fullText) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: fullText,
|
||||
}
|
||||
hasYieldedContent = true
|
||||
}
|
||||
return
|
||||
} else if (parsed.event === "error") {
|
||||
Logger.error("[DIFY DEBUG] Direct JSON Error event:", parsed)
|
||||
throw new Error(`Dify API error: ${parsed.message || "Unknown error"}`)
|
||||
} else if (parsed.answer || parsed.text || parsed.content) {
|
||||
// Fallback for any content in direct JSON
|
||||
const content = parsed.answer || parsed.text || parsed.content
|
||||
fullText += content
|
||||
yield {
|
||||
type: "text",
|
||||
text: fullText,
|
||||
}
|
||||
hasYieldedContent = true
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON, continue
|
||||
Logger.log("[DIFY DEBUG] Line is not direct JSON, continuing")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final check - if we haven't yielded any content, provide diagnostic information
|
||||
if (!hasYieldedContent) {
|
||||
const diagnosticInfo = {
|
||||
processedEvents,
|
||||
finalFullTextLength: fullText.length,
|
||||
finalFullText: fullText,
|
||||
streamDuration: Date.now() - lastEventTime,
|
||||
conversationId: this.conversationId,
|
||||
}
|
||||
Logger.error("[DIFY DEBUG] No content was yielded! Diagnostic info:", diagnosticInfo)
|
||||
|
||||
// If we have any accumulated text at all, yield it as a fallback
|
||||
if (fullText.trim()) {
|
||||
Logger.log("[DIFY DEBUG] Yielding accumulated text as fallback:", fullText)
|
||||
yield {
|
||||
type: "text",
|
||||
text: fullText,
|
||||
}
|
||||
} else {
|
||||
// Provide a more informative error
|
||||
throw new Error(
|
||||
`Dify API did not provide any assistant messages. ` +
|
||||
`Events processed: [${processedEvents.join(", ")}]. ` +
|
||||
`Check your Dify application configuration and ensure it's properly set up to return responses. ` +
|
||||
`API URL: ${fullUrl}. Conversation ID: ${this.conversationId || "none"}.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
Logger.log("[DIFY DEBUG] Stream reader released")
|
||||
}
|
||||
}
|
||||
|
||||
private convertMessagesToQuery(systemPrompt: string, messages: ClineStorageMessage[]): string {
|
||||
// Dify's context is managed by `conversation_id`. The `query` should be the last user message.
|
||||
// The system prompt is typically configured in the Dify App itself.
|
||||
const lastUserMessage = messages.filter((m) => m.role === "user").pop()
|
||||
|
||||
if (!lastUserMessage) {
|
||||
return "" // Should not happen in normal flow
|
||||
}
|
||||
|
||||
const userQuery = Array.isArray(lastUserMessage.content)
|
||||
? lastUserMessage.content.map((c) => ("text" in c ? c.text : "")).join("\n")
|
||||
: (lastUserMessage.content as string)
|
||||
|
||||
// Only prepend the system prompt if it's the very first message of a new conversation.
|
||||
if (!this.conversationId && systemPrompt) {
|
||||
Logger.log("[DIFY DEBUG] Prepending system prompt for new conversation.")
|
||||
return `${systemPrompt}\n\n---\n\n${userQuery}`
|
||||
}
|
||||
|
||||
return userQuery
|
||||
}
|
||||
|
||||
getModel(): { id: string; info: ModelInfo } {
|
||||
return {
|
||||
id: "dify-workflow",
|
||||
info: {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 128000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Dify workflow - model selection is configured in your Dify application",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Additional Dify API Methods
|
||||
|
||||
/**
|
||||
* Upload a file for use in conversations
|
||||
* @param file File buffer to upload
|
||||
* @param filename Name of the file
|
||||
* @param user User identifier (defaults to "cline-user")
|
||||
* @returns Promise with file upload response
|
||||
*/
|
||||
async uploadFile(file: Buffer, filename: string, user: string = "cline-user"): Promise<DifyFileResponse> {
|
||||
const formData = new FormData()
|
||||
formData.append("file", new Blob([new Uint8Array(file)]), filename)
|
||||
formData.append("user", user)
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/files/upload`, {
|
||||
method: "POST",
|
||||
headers: this.headers(),
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Dify file upload error: ${response.status} ${response.statusText} - ${errorText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop generation for a specific task
|
||||
* @param taskId Task ID from streaming response
|
||||
* @param user User identifier (defaults to "cline-user")
|
||||
* @returns Promise that resolves when generation is stopped
|
||||
*/
|
||||
async stopGeneration(taskId: string, user: string = "cline-user"): Promise<void> {
|
||||
const response = await fetch(`${this.baseUrl}/chat-messages/${taskId}/stop`, {
|
||||
method: "POST",
|
||||
headers: this.jsonHeaders(),
|
||||
body: JSON.stringify({ user }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Dify stop generation error: ${response.status} ${response.statusText} - ${errorText}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get conversation history messages with pagination
|
||||
* @param conversationId Conversation ID
|
||||
* @param user User identifier (defaults to "cline-user")
|
||||
* @param firstId First message ID for pagination (optional)
|
||||
* @param limit Number of messages to return (default: 20)
|
||||
* @returns Promise with conversation history
|
||||
*/
|
||||
async getConversationHistory(
|
||||
conversationId: string,
|
||||
user: string = "cline-user",
|
||||
firstId?: string,
|
||||
limit: number = 20,
|
||||
): Promise<DifyHistoryResponse> {
|
||||
const params = new URLSearchParams({ user, limit: limit.toString() })
|
||||
if (firstId) {
|
||||
params.append("first_id", firstId)
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/conversations/${conversationId}/messages?${params}`, {
|
||||
headers: this.headers(),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Dify get conversation history error: ${response.status} ${response.statusText} - ${errorText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of conversations for a user
|
||||
* @param user User identifier (defaults to "cline-user")
|
||||
* @param lastId Last conversation ID for pagination (optional)
|
||||
* @param limit Number of conversations to return (default: 20)
|
||||
* @param sortBy Sort field (default: "-updated_at")
|
||||
* @returns Promise with conversations list
|
||||
*/
|
||||
async getConversations(
|
||||
user: string = "cline-user",
|
||||
lastId?: string,
|
||||
limit: number = 20,
|
||||
sortBy: string = "-updated_at",
|
||||
): Promise<DifyConversationsResponse> {
|
||||
const params = new URLSearchParams({
|
||||
user,
|
||||
limit: limit.toString(),
|
||||
sort_by: sortBy,
|
||||
})
|
||||
if (lastId) {
|
||||
params.append("last_id", lastId)
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/conversations?${params}`, {
|
||||
headers: this.headers(),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Dify get conversations error: ${response.status} ${response.statusText} - ${errorText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a conversation
|
||||
* @param conversationId Conversation ID to delete
|
||||
* @param user User identifier (defaults to "cline-user")
|
||||
* @returns Promise that resolves when conversation is deleted
|
||||
*/
|
||||
async deleteConversation(conversationId: string, user: string = "cline-user"): Promise<void> {
|
||||
const response = await fetch(`${this.baseUrl}/conversations/${conversationId}`, {
|
||||
method: "DELETE",
|
||||
headers: this.jsonHeaders(),
|
||||
body: JSON.stringify({ user }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Dify delete conversation error: ${response.status} ${response.statusText} - ${errorText}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a conversation
|
||||
* @param conversationId Conversation ID to rename
|
||||
* @param user User identifier (defaults to "cline-user")
|
||||
* @param name New conversation name (optional if auto_generate is true)
|
||||
* @param autoGenerate Whether to auto-generate the name (default: false)
|
||||
* @returns Promise with updated conversation details
|
||||
*/
|
||||
async renameConversation(
|
||||
conversationId: string,
|
||||
user: string = "cline-user",
|
||||
name?: string,
|
||||
autoGenerate: boolean = false,
|
||||
): Promise<DifyConversationResponse> {
|
||||
const body: any = { user, auto_generate: autoGenerate }
|
||||
if (name) {
|
||||
body.name = name
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/conversations/${conversationId}/name`, {
|
||||
method: "POST",
|
||||
headers: this.jsonHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Dify rename conversation error: ${response.status} ${response.statusText} - ${errorText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit feedback for a message
|
||||
* @param messageId Message ID to provide feedback for
|
||||
* @param rating Rating: "like" or "dislike"
|
||||
* @param content Optional feedback content
|
||||
* @param user User identifier (defaults to "cline-user")
|
||||
* @returns Promise that resolves when feedback is submitted
|
||||
*/
|
||||
async submitMessageFeedback(
|
||||
messageId: string,
|
||||
rating: "like" | "dislike",
|
||||
content?: string,
|
||||
user: string = "cline-user",
|
||||
): Promise<void> {
|
||||
const body: any = { rating, user }
|
||||
if (content) {
|
||||
body.content = content
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/messages/${messageId}/feedbacks`, {
|
||||
method: "POST",
|
||||
headers: this.jsonHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Dify submit feedback error: ${response.status} ${response.statusText} - ${errorText}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current conversation ID
|
||||
* @returns Current conversation ID or null
|
||||
*/
|
||||
getCurrentConversationId(): string | null {
|
||||
return this.conversationId
|
||||
}
|
||||
|
||||
/**
|
||||
* Set conversation ID for continuing existing conversations
|
||||
* @param conversationId Conversation ID to set
|
||||
*/
|
||||
setConversationId(conversationId: string): void {
|
||||
this.conversationId = conversationId
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset conversation ID to start a new conversation
|
||||
*/
|
||||
resetConversation(): void {
|
||||
this.conversationId = null
|
||||
this.currentTaskId = null
|
||||
}
|
||||
|
||||
private jsonHeaders() {
|
||||
return {
|
||||
...this.headers(),
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
}
|
||||
|
||||
private headers() {
|
||||
const externalHeaders = buildExternalBasicHeaders()
|
||||
return {
|
||||
...externalHeaders,
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { DoubaoModelId, doubaoDefaultModelId, doubaoModels, ModelInfo } from "@shared/api"
|
||||
import OpenAI from "openai"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { createOpenAIClient } from "@/shared/net"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from ".."
|
||||
import { withRetry } from "../retry"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
|
||||
interface DoubaoHandlerOptions extends CommonApiHandlerOptions {
|
||||
doubaoApiKey?: string
|
||||
apiModelId?: string
|
||||
}
|
||||
|
||||
export class DoubaoHandler implements ApiHandler {
|
||||
private options: DoubaoHandlerOptions
|
||||
private client: OpenAI | undefined
|
||||
constructor(options: DoubaoHandlerOptions) {
|
||||
this.options = options
|
||||
}
|
||||
|
||||
private ensureClient(): OpenAI {
|
||||
if (!this.client) {
|
||||
if (!this.options.doubaoApiKey) {
|
||||
throw new Error("Doubao API key is required")
|
||||
}
|
||||
try {
|
||||
this.client = createOpenAIClient({
|
||||
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 } {
|
||||
const modelId = this.options.apiModelId
|
||||
if (modelId && modelId in doubaoModels) {
|
||||
const id = modelId as DoubaoModelId
|
||||
return { id, info: doubaoModels[id] }
|
||||
}
|
||||
return {
|
||||
id: doubaoDefaultModelId,
|
||||
info: doubaoModels[doubaoDefaultModelId],
|
||||
}
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
const model = this.getModel()
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...convertToOpenAiMessages(messages),
|
||||
]
|
||||
const stream = await client.chat.completions.create({
|
||||
model: model.id,
|
||||
max_completion_tokens: model.info.maxTokens,
|
||||
messages: openAiMessages,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
temperature: 0,
|
||||
})
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices?.[0]?.delta
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-expect-error-next-line
|
||||
cacheReadTokens: chunk.usage.prompt_cache_hit_tokens || 0,
|
||||
// @ts-expect-error-next-line
|
||||
cacheWriteTokens: chunk.usage.prompt_cache_miss_tokens || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import { FireworksModelId, fireworksDefaultModelId, fireworksModels, ModelInfo } from "@shared/api"
|
||||
import OpenAI from "openai"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { createOpenAIClient } from "@/shared/net"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from ".."
|
||||
import { withRetry } from "../retry"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
|
||||
interface FireworksHandlerOptions extends CommonApiHandlerOptions {
|
||||
fireworksApiKey?: string
|
||||
fireworksModelId?: string
|
||||
fireworksModelMaxCompletionTokens?: number
|
||||
fireworksModelMaxTokens?: number
|
||||
}
|
||||
|
||||
export class FireworksHandler implements ApiHandler {
|
||||
private options: FireworksHandlerOptions
|
||||
private client: OpenAI | undefined
|
||||
|
||||
constructor(options: FireworksHandlerOptions) {
|
||||
this.options = options
|
||||
}
|
||||
|
||||
private ensureClient(): OpenAI {
|
||||
if (!this.client) {
|
||||
if (!this.options.fireworksApiKey) {
|
||||
throw new Error("Fireworks API key is required")
|
||||
}
|
||||
try {
|
||||
this.client = createOpenAIClient({
|
||||
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: ClineStorageMessage[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
const modelId = this.options.fireworksModelId ?? ""
|
||||
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...convertToOpenAiMessages(messages),
|
||||
]
|
||||
|
||||
const stream = await client.chat.completions.create({
|
||||
model: modelId,
|
||||
messages: openAiMessages,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
temperature: 0,
|
||||
})
|
||||
|
||||
let reasoning: string | null = null
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices?.[0]?.delta
|
||||
if (reasoning || delta?.content?.includes("<think>")) {
|
||||
reasoning = (reasoning || "") + (delta.content ?? "")
|
||||
}
|
||||
|
||||
if (delta?.content && !reasoning) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
}
|
||||
}
|
||||
|
||||
if (reasoning || (delta && "reasoning_content" in delta && delta.reasoning_content)) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: delta.content || ((delta as any).reasoning_content as string | undefined) || "",
|
||||
}
|
||||
if (reasoning?.includes("</think>")) {
|
||||
// Reset so the next chunk is regular content
|
||||
reasoning = null
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
const usage = chunk.usage as OpenAI.CompletionUsage & {
|
||||
prompt_cache_hit_tokens?: number
|
||||
prompt_cache_miss_tokens?: number
|
||||
prompt_tokens_details?: {
|
||||
cached_tokens?: number
|
||||
}
|
||||
}
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: usage.prompt_tokens || 0,
|
||||
outputTokens: usage.completion_tokens || 0,
|
||||
// Fireworks can return cache hits either as prompt_cache_hit_tokens or prompt_tokens_details.cached_tokens.
|
||||
cacheReadTokens: usage.prompt_cache_hit_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? 0,
|
||||
cacheWriteTokens: usage.prompt_cache_miss_tokens || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getModel(): { id: FireworksModelId; info: ModelInfo } {
|
||||
const modelId = this.options.fireworksModelId
|
||||
if (modelId && modelId in fireworksModels) {
|
||||
const id = modelId as FireworksModelId
|
||||
return { id, info: fireworksModels[id] }
|
||||
}
|
||||
return {
|
||||
id: fireworksDefaultModelId,
|
||||
info: fireworksModels[fireworksDefaultModelId],
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user