mirror of
https://github.com/cline/cline.git
synced 2026-09-09 15:02:23 +08:00
Compare commits
103
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e3db0327a | ||
|
|
1f061087cb | ||
|
|
df0088dac7 | ||
|
|
8585e18001 | ||
|
|
194c44a0a2 | ||
|
|
3eb8da0931 | ||
|
|
7e43fb2ca5 | ||
|
|
cca5cbab8f | ||
|
|
7578353260 | ||
|
|
92bf6d6cae | ||
|
|
4f2cd1774c | ||
|
|
8454476682 | ||
|
|
0d86dd3d1d | ||
|
|
335fbfbbfa | ||
|
|
854339140c | ||
|
|
040733ec65 | ||
|
|
4b73a9a772 | ||
|
|
585152ed43 | ||
|
|
f8247f3e19 | ||
|
|
e44832af25 | ||
|
|
3a8d7ed604 | ||
|
|
ede6c09a21 | ||
|
|
c2adbc7cdf | ||
|
|
8582847e43 | ||
|
|
0ddfdb4c26 | ||
|
|
50be1840c3 | ||
|
|
819e1c29a4 | ||
|
|
9d09c5c854 | ||
|
|
1a15924246 | ||
|
|
a0b96e85c9 | ||
|
|
37bb76ad88 | ||
|
|
87c5534eb2 | ||
|
|
512b7f0462 | ||
|
|
59bf260eba | ||
|
|
1e559a91fa | ||
|
|
32bf0383cb | ||
|
|
93ad03ba00 | ||
|
|
c7929899dc | ||
|
|
abdeb145a2 | ||
|
|
bbfcf4371b | ||
|
|
be2b775aeb | ||
|
|
05d1a0bbca | ||
|
|
ed07d0c70a | ||
|
|
b93b60824c | ||
|
|
fcac115446 | ||
|
|
5fbe7420fd | ||
|
|
2c88af2800 | ||
|
|
6aed921f3d | ||
|
|
6c457de9d5 | ||
|
|
d2b742e72c | ||
|
|
c330834a76 | ||
|
|
8b6f53d0d6 | ||
|
|
3224307f53 | ||
|
|
1c3bab9eb9 | ||
|
|
ea7b54796f | ||
|
|
aa298789e9 | ||
|
|
f6585b90a7 | ||
|
|
d06595b1e1 | ||
|
|
7184db3bc8 | ||
|
|
d05b95b9a9 | ||
|
|
005c55bbdb | ||
|
|
4ce790f3dc | ||
|
|
6192d5c95d | ||
|
|
9c25929784 | ||
|
|
fafd4bbb23 | ||
|
|
35afe4130e | ||
|
|
f2f57d5ac2 | ||
|
|
622803b152 | ||
|
|
23b33c1acf | ||
|
|
0a4e197bdf | ||
|
|
d48f66d5a9 | ||
|
|
f4bedfe4b3 | ||
|
|
77a2334450 | ||
|
|
30237b0018 | ||
|
|
d922775244 | ||
|
|
3c7f6421e3 | ||
|
|
628aaa0675 | ||
|
|
af7fda87c0 | ||
|
|
9af6ced896 | ||
|
|
a3258dd79a | ||
|
|
ebb05ed963 | ||
|
|
8d03d176f2 | ||
|
|
32b3cfc081 | ||
|
|
a6500a07b4 | ||
|
|
81384089c4 | ||
|
|
6364792c47 | ||
|
|
40fc8879f1 | ||
|
|
e91cba4045 | ||
|
|
e5e1aa3455 | ||
|
|
d2893d2e93 | ||
|
|
4fc366df5f | ||
|
|
d8eb06318b | ||
|
|
fe4eb44c6b | ||
|
|
6c52bdc177 | ||
|
|
5260595472 | ||
|
|
a279388451 | ||
|
|
7810a81efe | ||
|
|
2a54e2a76e | ||
|
|
b7c38f76c9 | ||
|
|
d20e517831 | ||
|
|
fa3630da47 | ||
|
|
8229d0c9be | ||
|
|
efa14b6cab |
@@ -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):
|
||||
npm run protos && IS_DEV=true node esbuild.mjs
|
||||
|
||||
# Launch (skip-build if already built):
|
||||
npx tsx 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
|
||||
(`npm 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.
|
||||
+89
-87
@@ -13,11 +13,55 @@ 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
|
||||
- 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., `npm run compile`, not `npm 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.
|
||||
|
||||
@@ -48,93 +92,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.
|
||||
|
||||
@@ -199,3 +156,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 \
|
||||
npx tsx 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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -56,6 +59,10 @@ jobs:
|
||||
# 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
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
|
||||
@@ -93,13 +93,13 @@ jobs:
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
run: npm --prefix apps/vscode ci --include=optional
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
run: npm --prefix apps/vscode/webview-ui ci --include=optional
|
||||
|
||||
- name: Run Quality Checks (Parallel)
|
||||
run: npm run ci:check-all
|
||||
@@ -134,13 +134,13 @@ jobs:
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
run: npm --prefix apps/vscode ci --include=optional
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
run: npm --prefix apps/vscode/webview-ui ci --include=optional
|
||||
|
||||
- name: Set up NPM on Windows
|
||||
if: runner.os == 'Windows'
|
||||
@@ -160,6 +160,11 @@ jobs:
|
||||
id: build_step
|
||||
run: npm run ci:build
|
||||
|
||||
- name: Vitest Suites (SDK adapter + model catalog)
|
||||
id: vitest_tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: npm run test:vitest
|
||||
|
||||
- name: Unit Tests with coverage - Linux
|
||||
id: unit_tests_linux
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os == 'Linux' }}
|
||||
@@ -236,13 +241,13 @@ jobs:
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
run: npm --prefix apps/vscode ci --include=optional
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
run: npm --prefix apps/vscode/webview-ui ci --include=optional
|
||||
|
||||
- name: Download ripgrep binaries
|
||||
run: npm run download-ripgrep
|
||||
@@ -252,7 +257,7 @@ jobs:
|
||||
|
||||
- name: Install testing platform dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/testing-platform ci
|
||||
run: npm --prefix apps/vscode/testing-platform ci --include=optional
|
||||
|
||||
- name: Running testing platform integration spec tests
|
||||
timeout-minutes: 7
|
||||
|
||||
@@ -13,6 +13,9 @@ tmp
|
||||
pnpm-lock.yaml
|
||||
|
||||
.clineignore
|
||||
.cline/enterprise
|
||||
.cline/remote-config
|
||||
**/.cline/remote-config
|
||||
.venv
|
||||
.actrc
|
||||
CLAUDE.local.md
|
||||
|
||||
+2
-1
@@ -7,4 +7,5 @@ fi
|
||||
|
||||
gitleaks git --pre-commit --redact --staged --verbose || exit 1
|
||||
|
||||
lint-staged
|
||||
cd apps/vscode && lint-staged
|
||||
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.24
|
||||
|
||||
- Plugin commands can now submit prompts to the agent
|
||||
- Added support for overriding the API base URL
|
||||
- Open the verification URL automatically when starting device authentication
|
||||
- Enforced a single shared Cline Hub, so a stale hub is respawned after an upgrade
|
||||
- Suppressed flickering console windows on Windows
|
||||
- Fixed truncation of structured tool operation result strings so oversized tool output stays within limits
|
||||
- Stopped echoing the full command text in run_commands tool results
|
||||
|
||||
## 3.0.23
|
||||
|
||||
- Fixed Vertex AI GCP settings configuration
|
||||
|
||||
@@ -85,6 +85,20 @@ const result = await Bun.build({
|
||||
],
|
||||
define: {
|
||||
"process.env.NODE_ENV": '"production"',
|
||||
...(process.env.TELEMETRY_SERVICE_API_KEY
|
||||
? {
|
||||
"process.env.TELEMETRY_SERVICE_API_KEY": defineProcessEnv(
|
||||
"TELEMETRY_SERVICE_API_KEY",
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(process.env.ERROR_SERVICE_API_KEY
|
||||
? {
|
||||
"process.env.ERROR_SERVICE_API_KEY": defineProcessEnv(
|
||||
"ERROR_SERVICE_API_KEY",
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
"process.env.OTEL_TELEMETRY_ENABLED": defineProcessEnv(
|
||||
"OTEL_TELEMETRY_ENABLED",
|
||||
),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.23",
|
||||
"version": "3.0.24",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
@@ -87,6 +87,7 @@
|
||||
"open": "^10.2.0",
|
||||
"opentui-spinner": "^0.0.6",
|
||||
"pino": "^10.3.1",
|
||||
"posthog-node": "^5.8.0",
|
||||
"react": "19.2.4",
|
||||
"react-devtools-core": "^7.0.1",
|
||||
"react-reconciler": "0.32.0",
|
||||
|
||||
@@ -18,6 +18,8 @@ interface KeyStep {
|
||||
const INITIAL_RENDER_DELAY_SECONDS = 2.5;
|
||||
const POST_ACTION_SETTLE_SECONDS = 1.0;
|
||||
const INTERACTIVE_TEST_TIMEOUT_MS = 40_000;
|
||||
const HISTORY_PICKER_READY_DELAY_SECONDS = 8.0;
|
||||
const HISTORY_RESUME_READY_DELAY_SECONDS = 15.0;
|
||||
|
||||
function normalizeTerminalOutput(output: string): string {
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: this regex intentionally strips ANSI escape sequences
|
||||
@@ -51,16 +53,40 @@ function buildScriptCommand(scriptedInput: string, launchArgs: string): string {
|
||||
return `(${scriptedInput}) | script ${quietFlag} /dev/null ${toShellSingleQuotedLiteral(bunExec)} ${launchArgs}`;
|
||||
}
|
||||
|
||||
function runInteractiveCli(
|
||||
steps: KeyStep[],
|
||||
options?: { launchConfigView?: boolean },
|
||||
): CliResult {
|
||||
function createCliEnv(): NodeJS.ProcessEnv {
|
||||
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-home-"));
|
||||
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-data-"));
|
||||
const sessionDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-sessions-"));
|
||||
const teamDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-teams-"));
|
||||
tempDirs.push(homeDir, dataDir, sessionDir, teamDir);
|
||||
|
||||
return {
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
CLINE_DATA_DIR: dataDir,
|
||||
CLINE_DB_DATA_DIR: path.join(dataDir, "db"),
|
||||
CLINE_SESSION_DATA_DIR: sessionDir,
|
||||
CLINE_TEAM_DATA_DIR: teamDir,
|
||||
CLINE_SESSION_BACKEND_MODE: "local",
|
||||
CLINE_PROVIDER_SETTINGS_PATH: path.join(
|
||||
dataDir,
|
||||
"settings",
|
||||
"providers.json",
|
||||
),
|
||||
CLINE_HOOKS_LOG_PATH: path.join(dataDir, "logs", "hooks.jsonl"),
|
||||
};
|
||||
}
|
||||
|
||||
function runInteractiveCli(
|
||||
steps: KeyStep[],
|
||||
options?: {
|
||||
launchConfigView?: boolean;
|
||||
launchArgs?: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
},
|
||||
): CliResult {
|
||||
const env = options?.env ?? createCliEnv();
|
||||
|
||||
const scriptedInput = [
|
||||
...steps,
|
||||
// Exit each interactive run explicitly so tests do not idle until timeout.
|
||||
@@ -80,9 +106,13 @@ function runInteractiveCli(
|
||||
"-k",
|
||||
"test-key",
|
||||
];
|
||||
const launchArgs = [
|
||||
...(options?.launchConfigView ? [...baseArgs, "config"] : baseArgs),
|
||||
]
|
||||
const launchArgs = (
|
||||
options?.launchArgs
|
||||
? [cliEntry, ...options.launchArgs]
|
||||
: options?.launchConfigView
|
||||
? [...baseArgs, "config"]
|
||||
: baseArgs
|
||||
)
|
||||
.map((arg) => toShellSingleQuotedLiteral(arg))
|
||||
.join(" ");
|
||||
const command = buildScriptCommand(scriptedInput, launchArgs);
|
||||
@@ -90,21 +120,7 @@ function runInteractiveCli(
|
||||
return spawnSync("bash", ["-lc", command], {
|
||||
cwd: cliRoot,
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
CLINE_DATA_DIR: dataDir,
|
||||
CLINE_DB_DATA_DIR: path.join(dataDir, "db"),
|
||||
CLINE_SESSION_DATA_DIR: sessionDir,
|
||||
CLINE_TEAM_DATA_DIR: teamDir,
|
||||
CLINE_SESSION_BACKEND_MODE: "local",
|
||||
CLINE_PROVIDER_SETTINGS_PATH: path.join(
|
||||
dataDir,
|
||||
"settings",
|
||||
"providers.json",
|
||||
),
|
||||
CLINE_HOOKS_LOG_PATH: path.join(dataDir, "logs", "hooks.jsonl"),
|
||||
},
|
||||
env,
|
||||
timeout: INTERACTIVE_TEST_TIMEOUT_MS,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
@@ -188,6 +204,62 @@ describe("cli interactive e2e", () => {
|
||||
expect(output).toContain("/ for commands · @ for files");
|
||||
});
|
||||
|
||||
it("resumes a history-picked session and survives Ctrl+C without a native crash", {
|
||||
timeout: 120_000,
|
||||
}, () => {
|
||||
const env = createCliEnv();
|
||||
// Seed one session; the invalid key makes the run fail fast while
|
||||
// still persisting a resumable session record.
|
||||
const seed = spawnSync(
|
||||
bunExec,
|
||||
[
|
||||
cliEntry,
|
||||
"--provider",
|
||||
"anthropic",
|
||||
"-m",
|
||||
"claude-sonnet-4-6",
|
||||
"-k",
|
||||
"test-key",
|
||||
"hello",
|
||||
],
|
||||
{ cwd: cliRoot, encoding: "utf8", env, timeout: 60_000 },
|
||||
);
|
||||
expect(seed.error).toBeUndefined();
|
||||
const history = spawnSync(bunExec, [cliEntry, "history", "--json"], {
|
||||
cwd: cliRoot,
|
||||
encoding: "utf8",
|
||||
env,
|
||||
timeout: 60_000,
|
||||
});
|
||||
expect(history.error).toBeUndefined();
|
||||
expect(history.status).toBe(0);
|
||||
const historyRows = JSON.parse(history.stdout) as unknown[];
|
||||
expect(historyRows.length).toBeGreaterThan(0);
|
||||
|
||||
// history picker -> Enter resumes the seeded session in the
|
||||
// interactive TUI -> double Ctrl+C exits it. Regression guard for
|
||||
// the Bun "panic(main thread): Segmentation fault" that occurred
|
||||
// when the resumed TUI shared the picker's process (a second
|
||||
// OpenTUI renderer in one process crashes natively on teardown).
|
||||
const result = runInteractiveCli(
|
||||
[
|
||||
// Select the seeded session in the picker.
|
||||
{ delaySeconds: HISTORY_PICKER_READY_DELAY_SECONDS, input: "\r" },
|
||||
// Give the resumed TUI time to start, then double-press
|
||||
// Ctrl+C; the harness appends the final press 0.2s later.
|
||||
{ delaySeconds: HISTORY_RESUME_READY_DELAY_SECONDS, input: "\u0003" },
|
||||
],
|
||||
{ launchArgs: ["history"], env },
|
||||
);
|
||||
const output = outputOf(result);
|
||||
// The exit summary only prints after the resumed interactive TUI ran
|
||||
// and shut down cleanly; the history picker alone never prints it.
|
||||
expect(output).toContain("Session Summary");
|
||||
expect(output).not.toContain("panic(");
|
||||
expect(output).not.toContain("Segmentation fault");
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
|
||||
it("launches config view directly with `cline config`", () => {
|
||||
const result = runInteractiveCli(
|
||||
[{ delaySeconds: INITIAL_RENDER_DELAY_SECONDS, input: "" }],
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
createOAuthClientCallbacks,
|
||||
ensureCustomProvidersLoaded,
|
||||
getProviderAuthHandler,
|
||||
listLocalProviders,
|
||||
loginAndSaveProviderOAuthCredentials,
|
||||
type ProviderSettings,
|
||||
type ProviderSettingsManager,
|
||||
@@ -22,6 +21,8 @@ import {
|
||||
type OAuthCredentials,
|
||||
toProviderApiKey,
|
||||
} from "../utils/provider-auth";
|
||||
import { listLocalProviders } from "../utils/provider-catalog";
|
||||
import { identifyTelemetryAccount } from "../utils/telemetry";
|
||||
|
||||
export {
|
||||
getPersistedProviderApiKey,
|
||||
@@ -434,11 +435,15 @@ export async function runAuthProviderCommand(
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
await loginAndSaveProviderOAuthCredentials(
|
||||
const settings = await loginAndSaveProviderOAuthCredentials(
|
||||
providerSettingsManager,
|
||||
providerId,
|
||||
{ callbacks: createOAuthCallbacks(io) },
|
||||
);
|
||||
identifyTelemetryAccount({
|
||||
id: settings.auth?.accountId,
|
||||
provider: providerId,
|
||||
});
|
||||
io.writeln(
|
||||
`${c.green}You are now logged in to ${c.cyan}${providerId}${c.reset}`,
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@cline/shared/storage";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
collectPluginMcpOAuthCandidates,
|
||||
installPlugin,
|
||||
isOfficialPluginSlug,
|
||||
parsePluginSource,
|
||||
@@ -35,6 +36,7 @@ describe("plugin install command", () => {
|
||||
let originalHome: string | undefined;
|
||||
let originalClineDir: string | undefined;
|
||||
let originalClineDataDir: string | undefined;
|
||||
let originalMcpSettingsPath: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), "cli-plugin-install-"));
|
||||
@@ -43,6 +45,7 @@ describe("plugin install command", () => {
|
||||
originalHome = process.env.HOME;
|
||||
originalClineDir = process.env.CLINE_DIR;
|
||||
originalClineDataDir = process.env.CLINE_DATA_DIR;
|
||||
originalMcpSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
process.env.HOME = home;
|
||||
process.env.CLINE_DIR = join(home, ".cline");
|
||||
process.env.CLINE_DATA_DIR = join(home, ".cline", "data");
|
||||
@@ -91,6 +94,11 @@ describe("plugin install command", () => {
|
||||
} else {
|
||||
process.env.CLINE_DATA_DIR = originalClineDataDir;
|
||||
}
|
||||
if (originalMcpSettingsPath === undefined) {
|
||||
delete process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = originalMcpSettingsPath;
|
||||
}
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -676,11 +684,341 @@ describe("plugin install command", () => {
|
||||
expect(code).toBe(0);
|
||||
const parsed = JSON.parse(stdout.join("")) as { installPath: string };
|
||||
expect(parsed.installPath).toContain(join(home, ".cline", "plugins"));
|
||||
expect("mcpOAuthCandidates" in parsed).toBe(false);
|
||||
} finally {
|
||||
process.stdout.write = originalWrite;
|
||||
}
|
||||
});
|
||||
|
||||
it("does not run MCP OAuth follow-up for JSON plugin installs", async () => {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
|
||||
const source = join(root, "json-oauth-mcp-plugin.js");
|
||||
writeFileSync(
|
||||
source,
|
||||
`
|
||||
export default {
|
||||
name: "json-oauth-mcp-plugin",
|
||||
manifest: { capabilities: ["mcp"] },
|
||||
setup(api) {
|
||||
api.registerMcpServer({
|
||||
name: "json-oauth-docs",
|
||||
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const stdout: string[] = [];
|
||||
const originalWrite = process.stdout.write;
|
||||
const authorize = vi.fn();
|
||||
process.stdout.write = ((chunk: string | Uint8Array) => {
|
||||
stdout.push(String(chunk));
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
try {
|
||||
const code = await runPluginInstallCommand({
|
||||
source,
|
||||
json: true,
|
||||
io: {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
mcpOAuth: {
|
||||
interactive: true,
|
||||
selectCandidates: async (candidates) => candidates,
|
||||
authorize,
|
||||
},
|
||||
});
|
||||
expect(code).toBe(0);
|
||||
expect(authorize).not.toHaveBeenCalled();
|
||||
const parsed = JSON.parse(stdout.join("")) as {
|
||||
installPath: string;
|
||||
mcpOAuthCandidates?: unknown;
|
||||
};
|
||||
expect(parsed.installPath).toContain(join(home, ".cline", "plugins"));
|
||||
expect(parsed.mcpOAuthCandidates).toBeUndefined();
|
||||
} finally {
|
||||
process.stdout.write = originalWrite;
|
||||
}
|
||||
});
|
||||
|
||||
it("warns when plugin MCP settings sync fails after install", async () => {
|
||||
const source = join(root, "mcp-plugin.js");
|
||||
writeFileSync(
|
||||
source,
|
||||
`
|
||||
export default {
|
||||
name: "mcp-plugin",
|
||||
manifest: { capabilities: ["mcp"] },
|
||||
setup(api) {
|
||||
api.registerMcpServer({
|
||||
name: "mcp-plugin",
|
||||
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const blockedDirectory = join(root, "not-a-directory");
|
||||
writeFileSync(blockedDirectory, "file", "utf8");
|
||||
const originalSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = join(
|
||||
blockedDirectory,
|
||||
"cline_mcp_settings.json",
|
||||
);
|
||||
const output: string[] = [];
|
||||
try {
|
||||
const code = await runPluginInstallCommand({
|
||||
source,
|
||||
io: {
|
||||
writeln: (text = "") => output.push(text),
|
||||
writeErr: (text) => output.push(text),
|
||||
},
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(output.join("\n")).toContain("Installed plugin from");
|
||||
expect(output.join("\n")).toContain(
|
||||
"Warning: failed to sync plugin MCP servers",
|
||||
);
|
||||
expect(output.join("\n")).toContain("mcp-plugin");
|
||||
} finally {
|
||||
if (originalSettingsPath === undefined) {
|
||||
delete process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = originalSettingsPath;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("detects plugin-owned remote MCP servers as OAuth candidates", async () => {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
|
||||
const source = join(root, "oauth-mcp-plugin.js");
|
||||
writeFileSync(
|
||||
source,
|
||||
`
|
||||
export default {
|
||||
name: "oauth-mcp-plugin",
|
||||
manifest: { capabilities: ["mcp"] },
|
||||
setup(api) {
|
||||
api.registerMcpServer({
|
||||
name: "oauth-docs",
|
||||
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await installPlugin({ source });
|
||||
|
||||
expect(result.mcpOAuthCandidates).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "oauth-docs",
|
||||
pluginName: "oauth-mcp-plugin",
|
||||
transportType: "streamableHttp",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not treat remote MCP servers with static headers as OAuth candidates", async () => {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
|
||||
const source = join(root, "headers-mcp-plugin.js");
|
||||
writeFileSync(
|
||||
source,
|
||||
`
|
||||
export default {
|
||||
name: "headers-mcp-plugin",
|
||||
manifest: { capabilities: ["mcp"] },
|
||||
setup(api) {
|
||||
api.registerMcpServer({
|
||||
name: "headers-docs",
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://example.com/mcp",
|
||||
headers: { Authorization: "Bearer token" },
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await installPlugin({ source });
|
||||
|
||||
expect(result.mcpOAuthCandidates).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips plugin MCP OAuth candidates that already have tokens", async () => {
|
||||
const settingsPath = join(root, "mcp-settings.json");
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
|
||||
const source = join(root, "authorized-mcp-plugin.js");
|
||||
writeFileSync(
|
||||
source,
|
||||
`
|
||||
export default {
|
||||
name: "authorized-mcp-plugin",
|
||||
manifest: { capabilities: ["mcp"] },
|
||||
setup(api) {
|
||||
api.registerMcpServer({
|
||||
name: "authorized-docs",
|
||||
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const result = await installPlugin({ source });
|
||||
const settings = JSON.parse(readFileSync(settingsPath, "utf8")) as {
|
||||
mcpServers?: Record<string, { oauth?: unknown }>;
|
||||
};
|
||||
const server = settings.mcpServers?.["authorized-docs"];
|
||||
if (!server) {
|
||||
throw new Error("Expected authorized-docs MCP server to be written");
|
||||
}
|
||||
server.oauth = { tokens: { access_token: "oauth-token" } };
|
||||
writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf8");
|
||||
|
||||
expect(
|
||||
collectPluginMcpOAuthCandidates({
|
||||
pluginPaths: result.entryPaths,
|
||||
settingsPath,
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("authorizes selected plugin MCP OAuth candidates during interactive installs", async () => {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
|
||||
const source = join(root, "interactive-mcp-plugin.js");
|
||||
writeFileSync(
|
||||
source,
|
||||
`
|
||||
export default {
|
||||
name: "interactive-mcp-plugin",
|
||||
manifest: { capabilities: ["mcp"] },
|
||||
setup(api) {
|
||||
api.registerMcpServer({
|
||||
name: "interactive-docs",
|
||||
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const authorized: string[] = [];
|
||||
const output: string[] = [];
|
||||
|
||||
const code = await runPluginInstallCommand({
|
||||
source,
|
||||
io: {
|
||||
writeln: (text = "") => output.push(text),
|
||||
writeErr: (text) => output.push(text),
|
||||
},
|
||||
mcpOAuth: {
|
||||
interactive: true,
|
||||
selectCandidates: async (candidates) => candidates,
|
||||
authorize: async (candidate) => {
|
||||
authorized.push(candidate.name);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(authorized).toEqual(["interactive-docs"]);
|
||||
expect(output.join("\n")).toContain("Installed plugin from");
|
||||
});
|
||||
|
||||
it("keeps plugin install successful when MCP OAuth authorization fails", async () => {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
|
||||
const source = join(root, "failing-oauth-mcp-plugin.js");
|
||||
writeFileSync(
|
||||
source,
|
||||
`
|
||||
export default {
|
||||
name: "failing-oauth-mcp-plugin",
|
||||
manifest: { capabilities: ["mcp"] },
|
||||
setup(api) {
|
||||
api.registerMcpServer({
|
||||
name: "failing-docs",
|
||||
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const output: string[] = [];
|
||||
|
||||
const code = await runPluginInstallCommand({
|
||||
source,
|
||||
io: {
|
||||
writeln: (text = "") => output.push(text),
|
||||
writeErr: (text) => output.push(text),
|
||||
},
|
||||
mcpOAuth: {
|
||||
interactive: true,
|
||||
selectCandidates: async (candidates) => candidates,
|
||||
authorize: async () => {
|
||||
throw new Error("oauth unavailable");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(output.join("\n")).toContain(
|
||||
"Warning: failed to authorize MCP server failing-docs: oauth unavailable",
|
||||
);
|
||||
});
|
||||
|
||||
it("prints guidance for plugin MCP OAuth candidates in non-interactive installs", async () => {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
|
||||
const source = join(root, "non-interactive-mcp-plugin.js");
|
||||
writeFileSync(
|
||||
source,
|
||||
`
|
||||
export default {
|
||||
name: "non-interactive-mcp-plugin",
|
||||
manifest: { capabilities: ["mcp"] },
|
||||
setup(api) {
|
||||
api.registerMcpServer({
|
||||
name: "non-interactive-docs",
|
||||
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const output: string[] = [];
|
||||
const authorize = vi.fn();
|
||||
|
||||
const code = await runPluginInstallCommand({
|
||||
source,
|
||||
io: {
|
||||
writeln: (text = "") => output.push(text),
|
||||
writeErr: (text) => output.push(text),
|
||||
},
|
||||
mcpOAuth: {
|
||||
interactive: false,
|
||||
authorize,
|
||||
},
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(authorize).not.toHaveBeenCalled();
|
||||
expect(output.join("\n")).toContain(
|
||||
"Plugin MCP servers may require OAuth authorization",
|
||||
);
|
||||
expect(output.join("\n")).toContain("non-interactive-docs");
|
||||
expect(output.join("\n")).toContain('Run "cline mcp"');
|
||||
});
|
||||
|
||||
it("prints JSON output for official plugin installs", async () => {
|
||||
const officialPluginsRepo = await createOfficialPluginsRepo({
|
||||
"json-plugin": {
|
||||
|
||||
@@ -21,7 +21,15 @@ import {
|
||||
resolve,
|
||||
sep,
|
||||
} from "node:path";
|
||||
import { type PluginUninstallOptions, uninstallPlugin } from "@cline/core";
|
||||
import {
|
||||
type McpServerRegistration,
|
||||
type PluginMcpSettingsSyncResult,
|
||||
type PluginUninstallOptions,
|
||||
resolveDefaultMcpSettingsPath,
|
||||
resolveMcpServerRegistrations,
|
||||
syncPluginMcpServersToSettings,
|
||||
uninstallPlugin,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
isPluginModulePath,
|
||||
resolveClineDir,
|
||||
@@ -36,12 +44,31 @@ export interface PluginInstallOptions {
|
||||
npmCommand?: string;
|
||||
officialPluginsRepo?: string;
|
||||
io?: PluginInstallIo;
|
||||
mcpOAuth?: PluginInstallMcpOAuthOptions;
|
||||
}
|
||||
|
||||
export interface PluginInstallResult {
|
||||
source: string;
|
||||
installPath: string;
|
||||
entryPaths: string[];
|
||||
mcpSyncFailures: PluginMcpSettingsSyncResult["failures"];
|
||||
mcpOAuthCandidates: PluginMcpOAuthCandidate[];
|
||||
}
|
||||
|
||||
export interface PluginMcpOAuthCandidate {
|
||||
name: string;
|
||||
pluginName: string;
|
||||
pluginPath: string;
|
||||
transportType: "sse" | "streamableHttp";
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
export interface PluginInstallMcpOAuthOptions {
|
||||
interactive?: boolean;
|
||||
selectCandidates?: (
|
||||
candidates: PluginMcpOAuthCandidate[],
|
||||
) => Promise<PluginMcpOAuthCandidate[]>;
|
||||
authorize?: (candidate: PluginMcpOAuthCandidate) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginInstallIo {
|
||||
@@ -1005,6 +1032,81 @@ function replaceInstallPath(
|
||||
}
|
||||
}
|
||||
|
||||
function hasStaticHeaders(registration: McpServerRegistration): boolean {
|
||||
const transport = registration.transport;
|
||||
if (transport.type === "stdio") {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
transport.headers !== undefined && Object.keys(transport.headers).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function hasOAuthAccessToken(registration: McpServerRegistration): boolean {
|
||||
const accessToken = registration.oauth?.tokens?.access_token;
|
||||
return typeof accessToken === "string" && accessToken.trim().length > 0;
|
||||
}
|
||||
|
||||
function getPluginOwner(
|
||||
registration: McpServerRegistration,
|
||||
): { pluginName: string; pluginPath: string } | undefined {
|
||||
const metadata = registration.metadata;
|
||||
if (
|
||||
!metadata ||
|
||||
metadata.source !== "plugin" ||
|
||||
typeof metadata.pluginName !== "string" ||
|
||||
typeof metadata.pluginPath !== "string"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
pluginName: metadata.pluginName,
|
||||
pluginPath: metadata.pluginPath,
|
||||
};
|
||||
}
|
||||
|
||||
export function collectPluginMcpOAuthCandidates(input: {
|
||||
pluginPaths: readonly string[];
|
||||
settingsPath?: string;
|
||||
}): PluginMcpOAuthCandidate[] {
|
||||
const pluginPaths = new Set(input.pluginPaths.map((path) => resolve(path)));
|
||||
if (pluginPaths.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let registrations: McpServerRegistration[];
|
||||
try {
|
||||
registrations = resolveMcpServerRegistrations({
|
||||
filePath: input.settingsPath ?? resolveDefaultMcpSettingsPath(),
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidates: PluginMcpOAuthCandidate[] = [];
|
||||
for (const registration of registrations) {
|
||||
const owner = getPluginOwner(registration);
|
||||
if (!owner || !pluginPaths.has(resolve(owner.pluginPath))) {
|
||||
continue;
|
||||
}
|
||||
const transportType = registration.transport.type;
|
||||
if (transportType === "stdio") {
|
||||
continue;
|
||||
}
|
||||
if (hasStaticHeaders(registration) || hasOAuthAccessToken(registration)) {
|
||||
continue;
|
||||
}
|
||||
candidates.push({
|
||||
name: registration.name,
|
||||
pluginName: owner.pluginName,
|
||||
pluginPath: owner.pluginPath,
|
||||
transportType,
|
||||
lastError: registration.oauth?.lastError,
|
||||
});
|
||||
}
|
||||
return candidates.sort((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
export async function installPlugin(
|
||||
options: PluginInstallOptions,
|
||||
): Promise<PluginInstallResult> {
|
||||
@@ -1071,28 +1173,161 @@ export async function installPlugin(
|
||||
}
|
||||
|
||||
replaceInstallPath(stagingRoot, installPath, force);
|
||||
return {
|
||||
const result = {
|
||||
source,
|
||||
installPath,
|
||||
entryPaths: entryPaths.map((entry) => resolve(installPath, entry)),
|
||||
mcpSyncFailures: [] as PluginMcpSettingsSyncResult["failures"],
|
||||
mcpOAuthCandidates: [] as PluginMcpOAuthCandidate[],
|
||||
};
|
||||
const syncResult = await syncPluginMcpServersToSettings({
|
||||
pluginPaths: result.entryPaths,
|
||||
cwd,
|
||||
workspacePath: cwd,
|
||||
});
|
||||
result.mcpSyncFailures = syncResult.failures;
|
||||
result.mcpOAuthCandidates = collectPluginMcpOAuthCandidates({
|
||||
pluginPaths: result.entryPaths,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
rmSync(stagingRoot, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function serializePluginInstallResult(
|
||||
result: PluginInstallResult,
|
||||
): Omit<PluginInstallResult, "mcpOAuthCandidates"> {
|
||||
return {
|
||||
source: result.source,
|
||||
installPath: result.installPath,
|
||||
entryPaths: result.entryPaths,
|
||||
mcpSyncFailures: result.mcpSyncFailures,
|
||||
};
|
||||
}
|
||||
|
||||
function isInteractivePluginInstall(
|
||||
options: PluginInstallOptions & { json?: boolean },
|
||||
): boolean {
|
||||
return (
|
||||
options.mcpOAuth?.interactive ??
|
||||
(options.json !== true && process.stdin.isTTY && process.stdout.isTTY)
|
||||
);
|
||||
}
|
||||
|
||||
async function selectMcpOAuthCandidatesWithClack(
|
||||
candidates: PluginMcpOAuthCandidate[],
|
||||
): Promise<PluginMcpOAuthCandidate[]> {
|
||||
const p = await import("@clack/prompts");
|
||||
const action = await p.select({
|
||||
message: "Authorize plugin MCP servers now?",
|
||||
options: [
|
||||
{
|
||||
value: "all",
|
||||
label: "Authorize all",
|
||||
hint: "open browser authorization for each server",
|
||||
},
|
||||
{
|
||||
value: "choose",
|
||||
label: "Choose servers",
|
||||
hint: "select which servers to authorize",
|
||||
},
|
||||
{
|
||||
value: "skip",
|
||||
label: "Skip",
|
||||
},
|
||||
],
|
||||
});
|
||||
if (p.isCancel(action) || action === "skip") {
|
||||
return [];
|
||||
}
|
||||
if (action === "all") {
|
||||
return candidates;
|
||||
}
|
||||
|
||||
const selectedNames = await p.multiselect({
|
||||
message: "Select MCP servers to authorize",
|
||||
options: candidates.map((candidate) => ({
|
||||
value: candidate.name,
|
||||
label: candidate.name,
|
||||
hint: `${candidate.transportType} [${candidate.pluginName}]`,
|
||||
})),
|
||||
required: false,
|
||||
});
|
||||
if (p.isCancel(selectedNames) || !Array.isArray(selectedNames)) {
|
||||
return [];
|
||||
}
|
||||
const selected = new Set(selectedNames);
|
||||
return candidates.filter((candidate) => selected.has(candidate.name));
|
||||
}
|
||||
|
||||
async function authorizeMcpOAuthCandidate(
|
||||
candidate: PluginMcpOAuthCandidate,
|
||||
): Promise<void> {
|
||||
const { authorizeMcpServerOAuthWithBrowser } = await import(
|
||||
"../wizards/mcp/oauth"
|
||||
);
|
||||
await authorizeMcpServerOAuthWithBrowser(candidate.name);
|
||||
}
|
||||
|
||||
async function runPluginMcpOAuthFollowup(
|
||||
candidates: PluginMcpOAuthCandidate[],
|
||||
options: PluginInstallOptions & { json?: boolean },
|
||||
): Promise<void> {
|
||||
if (candidates.length === 0 || options.json === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isInteractivePluginInstall(options)) {
|
||||
options.io?.writeln("Plugin MCP servers may require OAuth authorization:");
|
||||
for (const candidate of candidates) {
|
||||
options.io?.writeln(
|
||||
` ${candidate.name} (${candidate.transportType}, plugin: ${candidate.pluginName})`,
|
||||
);
|
||||
}
|
||||
options.io?.writeln(
|
||||
'Run "cline mcp" and choose "Authorize OAuth" to authorize them.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const selected =
|
||||
options.mcpOAuth?.selectCandidates !== undefined
|
||||
? await options.mcpOAuth.selectCandidates(candidates)
|
||||
: await selectMcpOAuthCandidatesWithClack(candidates);
|
||||
const authorize = options.mcpOAuth?.authorize ?? authorizeMcpOAuthCandidate;
|
||||
for (const candidate of selected) {
|
||||
try {
|
||||
await authorize(candidate);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.io?.writeErr(
|
||||
`Warning: failed to authorize MCP server ${candidate.name}: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runPluginInstallCommand(
|
||||
options: PluginInstallOptions & { json?: boolean },
|
||||
): Promise<number> {
|
||||
try {
|
||||
const result = await installPlugin(options);
|
||||
if (options.json) {
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
process.stdout.write(
|
||||
JSON.stringify(serializePluginInstallResult(result)),
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
options.io?.writeln(`Installed plugin from ${result.source}`);
|
||||
options.io?.writeln(` Path: ${result.installPath}`);
|
||||
for (const failure of result.mcpSyncFailures) {
|
||||
options.io?.writeErr(
|
||||
`Warning: failed to sync plugin MCP servers for ${failure.pluginName ?? failure.pluginPath}: ${failure.message}`,
|
||||
);
|
||||
}
|
||||
await runPluginMcpOAuthFollowup(result.mcpOAuthCandidates, options);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
mockGetLastUsedProviderSettings,
|
||||
mockGetProviderSettings,
|
||||
mockResolveSystemPrompt,
|
||||
mockGetProviderCollection,
|
||||
mockGetBooleanFlagEnabled,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetLastUsedProviderSettings: vi.fn(),
|
||||
mockGetProviderSettings: vi.fn(),
|
||||
mockResolveSystemPrompt: vi.fn(),
|
||||
mockGetProviderCollection: vi.fn(),
|
||||
mockGetBooleanFlagEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
@@ -18,8 +20,8 @@ vi.mock("@cline/core", async () => {
|
||||
return {
|
||||
...actual,
|
||||
ProviderSettingsManager: class {
|
||||
getLastUsedProviderSettings() {
|
||||
return mockGetLastUsedProviderSettings();
|
||||
getLastUsedProviderSettings(options?: unknown) {
|
||||
return mockGetLastUsedProviderSettings(options);
|
||||
}
|
||||
|
||||
getProviderSettings(providerId: string) {
|
||||
@@ -43,6 +45,12 @@ vi.mock("../utils/helpers", () => ({
|
||||
resolveWorkspaceRoot: vi.fn((cwd: string) => cwd),
|
||||
}));
|
||||
|
||||
vi.mock("../utils/feature-flags", () => ({
|
||||
getCliFeatureFlagsService: () => ({
|
||||
getBooleanFlagEnabled: mockGetBooleanFlagEnabled,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../commands/auth", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../commands/auth")>(
|
||||
@@ -57,6 +65,10 @@ vi.mock("../commands/auth", async () => {
|
||||
import { buildConnectorStartRequest } from "./session-runtime";
|
||||
|
||||
describe("buildConnectorStartRequest", () => {
|
||||
beforeEach(() => {
|
||||
mockGetBooleanFlagEnabled.mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env.OPENROUTER_API_KEY;
|
||||
@@ -88,5 +100,64 @@ describe("buildConnectorStartRequest", () => {
|
||||
expect(request.provider).toBe("openrouter");
|
||||
expect(request.apiKey).toBe("env-openrouter-key");
|
||||
expect(request.model).toBe("anthropic/claude-sonnet-4.6");
|
||||
expect(mockGetLastUsedProviderSettings).toHaveBeenCalledWith({
|
||||
isClinePassEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses auth material resolved by provider settings manager", async () => {
|
||||
mockGetLastUsedProviderSettings.mockReturnValue({ provider: "cline-pass" });
|
||||
mockGetProviderSettings.mockReturnValue({
|
||||
provider: "cline-pass",
|
||||
auth: { accessToken: "workos:resolved-token" },
|
||||
});
|
||||
mockGetProviderCollection.mockReturnValue({
|
||||
provider: { env: ["CLINE_API_KEY"] },
|
||||
});
|
||||
mockResolveSystemPrompt.mockResolvedValue("system");
|
||||
|
||||
const request = await buildConnectorStartRequest({
|
||||
options: {
|
||||
cwd: "/tmp/work",
|
||||
mode: "act",
|
||||
enableTools: false,
|
||||
},
|
||||
io: { writeln: vi.fn(), writeErr: vi.fn() },
|
||||
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
|
||||
systemRules: "Rules",
|
||||
defaultModel: "cline-pass/glm-5.1",
|
||||
});
|
||||
|
||||
expect(request.provider).toBe("cline-pass");
|
||||
expect(request.apiKey).toBe("workos:resolved-token");
|
||||
expect(request.model).toBe("cline-pass/glm-5.1");
|
||||
});
|
||||
|
||||
it("uses auth material resolved by provider settings manager", async () => {
|
||||
mockGetLastUsedProviderSettings.mockReturnValue({ provider: "cline-pass" });
|
||||
mockGetProviderSettings.mockReturnValue({
|
||||
provider: "cline-pass",
|
||||
auth: { accessToken: "workos:resolved-token" },
|
||||
});
|
||||
mockGetProviderCollection.mockReturnValue({
|
||||
provider: { env: ["CLINE_API_KEY"] },
|
||||
});
|
||||
mockResolveSystemPrompt.mockResolvedValue("system");
|
||||
|
||||
const request = await buildConnectorStartRequest({
|
||||
options: {
|
||||
cwd: "/tmp/work",
|
||||
mode: "act",
|
||||
enableTools: false,
|
||||
},
|
||||
io: { writeln: vi.fn(), writeErr: vi.fn() },
|
||||
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
|
||||
systemRules: "Rules",
|
||||
defaultModel: "cline-pass/glm-5.1",
|
||||
});
|
||||
|
||||
expect(request.provider).toBe("cline-pass");
|
||||
expect(request.apiKey).toBe("workos:resolved-token");
|
||||
expect(request.model).toBe("cline-pass/glm-5.1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import type { CliLoggerAdapter } from "../logging/adapter";
|
||||
import { resolveSystemPrompt } from "../runtime/prompt";
|
||||
import { resolveCliSessionMetadata } from "../utils/enterprise";
|
||||
import { getCliFeatureFlagsService } from "../utils/feature-flags";
|
||||
import { resolveWorkspaceRoot } from "../utils/helpers";
|
||||
import {
|
||||
parseLocalRowMetadata,
|
||||
@@ -62,7 +63,10 @@ export async function buildConnectorStartRequest(input: {
|
||||
}): Promise<ChatStartSessionRequest> {
|
||||
const providerSettingsManager = new ProviderSettingsManager();
|
||||
const lastUsedProviderSettings =
|
||||
providerSettingsManager.getLastUsedProviderSettings();
|
||||
providerSettingsManager.getLastUsedProviderSettings({
|
||||
isClinePassEnabled:
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
|
||||
});
|
||||
const provider = normalizeProviderId(
|
||||
input.options.provider?.trim() ||
|
||||
lastUsedProviderSettings?.provider ||
|
||||
|
||||
@@ -29,7 +29,9 @@ const authMocks = vi.hoisted(() => ({
|
||||
runAuthCommand: vi.fn(),
|
||||
}));
|
||||
const providerSettingsMocks = vi.hoisted(() => ({
|
||||
getLastUsedProviderSettings: vi.fn<() => unknown>(() => undefined),
|
||||
getLastUsedProviderSettings: vi.fn<(options?: unknown) => unknown>(
|
||||
() => undefined,
|
||||
),
|
||||
getProviderConfig: vi.fn<(providerId: string, options?: unknown) => unknown>(
|
||||
() => undefined,
|
||||
),
|
||||
@@ -82,6 +84,11 @@ const historyMocks = vi.hoisted(() => ({
|
||||
runHistoryExport: vi.fn(async () => 0),
|
||||
runHistoryUpdate: vi.fn(async () => 0),
|
||||
}));
|
||||
const historyResumeMocks = vi.hoisted(() => ({
|
||||
spawnHistoryResume: vi.fn<() => Promise<number | undefined>>(
|
||||
async () => undefined,
|
||||
),
|
||||
}));
|
||||
const loggingMocks = vi.hoisted(() => ({
|
||||
createCliLoggerAdapter: vi.fn(() => ({
|
||||
core: {
|
||||
@@ -101,10 +108,13 @@ const hubRuntimeMocks = vi.hoisted(() => ({
|
||||
}));
|
||||
const telemetryMocks = vi.hoisted(() => ({
|
||||
captureCliExtensionActivated: vi.fn(),
|
||||
identifyCliTelemetryAccount: vi.fn(),
|
||||
identifyTelemetryAccount: vi.fn(),
|
||||
getCliTelemetryService: vi.fn(),
|
||||
disposeCliTelemetryService: vi.fn(async () => {}),
|
||||
}));
|
||||
const featureFlagMocks = vi.hoisted(() => ({
|
||||
getBooleanFlagEnabled: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
function forcePromptModeInput() {
|
||||
Object.defineProperty(process.stdin, "isTTY", {
|
||||
@@ -148,8 +158,8 @@ vi.mock("@cline/core", () => {
|
||||
stop: vi.fn(),
|
||||
})),
|
||||
ProviderSettingsManager: class {
|
||||
getLastUsedProviderSettings() {
|
||||
return providerSettingsMocks.getLastUsedProviderSettings();
|
||||
getLastUsedProviderSettings(options?: unknown) {
|
||||
return providerSettingsMocks.getLastUsedProviderSettings(options);
|
||||
}
|
||||
getProviderSettings(providerId: string) {
|
||||
return providerSettingsMocks.getProviderSettings(providerId);
|
||||
@@ -164,6 +174,12 @@ vi.mock("@cline/core", () => {
|
||||
};
|
||||
});
|
||||
vi.mock("./utils/provider-auth", () => authMocks);
|
||||
vi.mock("./utils/feature-flags", () => ({
|
||||
getCliFeatureFlagsService: () => ({
|
||||
getBooleanFlagEnabled: featureFlagMocks.getBooleanFlagEnabled,
|
||||
}),
|
||||
refreshCliFeatureFlagsInBackground: vi.fn(),
|
||||
}));
|
||||
vi.mock("./runtime/prompt", () => ({
|
||||
resolveSystemPrompt: promptMocks.resolveSystemPrompt,
|
||||
}));
|
||||
@@ -172,6 +188,7 @@ vi.mock("./commands/dashboard", () => dashboardMocks);
|
||||
vi.mock("./kanban-migration/notice", () => migrationNoticeMocks);
|
||||
vi.mock("./commands/update", () => updateMocks);
|
||||
vi.mock("./commands/history", () => historyMocks);
|
||||
vi.mock("./utils/history-resume", () => historyResumeMocks);
|
||||
vi.mock("./logging/adapter", () => loggingMocks);
|
||||
vi.mock("./utils/hub-runtime", () => hubRuntimeMocks);
|
||||
vi.mock("./utils/telemetry", () => telemetryMocks);
|
||||
@@ -191,6 +208,8 @@ describe("runCli lightweight command dispatch", () => {
|
||||
historyMocks.runHistoryExport.mockResolvedValue(0);
|
||||
historyMocks.runHistoryUpdate.mockReset();
|
||||
historyMocks.runHistoryUpdate.mockResolvedValue(0);
|
||||
historyResumeMocks.spawnHistoryResume.mockReset();
|
||||
historyResumeMocks.spawnHistoryResume.mockResolvedValue(undefined);
|
||||
sessionMocks.getSessionRow.mockReset();
|
||||
sessionMocks.getSessionRow.mockResolvedValue({
|
||||
sessionId: "sess_123",
|
||||
@@ -246,7 +265,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
updateMocks.getPreferredKanbanInstaller.mockReset();
|
||||
updateMocks.getPreferredKanbanInstaller.mockReturnValue(undefined);
|
||||
telemetryMocks.captureCliExtensionActivated.mockReset();
|
||||
telemetryMocks.identifyCliTelemetryAccount.mockReset();
|
||||
telemetryMocks.identifyTelemetryAccount.mockReset();
|
||||
telemetryMocks.getCliTelemetryService.mockReset();
|
||||
telemetryMocks.disposeCliTelemetryService.mockReset();
|
||||
telemetryMocks.disposeCliTelemetryService.mockResolvedValue(undefined);
|
||||
@@ -719,10 +738,47 @@ describe("runCli lightweight command dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("forces chat view when resuming from history picker", async () => {
|
||||
it("resumes a history-picked session in a child process", async () => {
|
||||
historyMocks.runHistoryList.mockImplementationOnce(
|
||||
async () => "sess_from_history",
|
||||
);
|
||||
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(0);
|
||||
process.argv = ["bun", "src/index.ts", "history"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(historyResumeMocks.spawnHistoryResume).toHaveBeenCalledTimes(1);
|
||||
expect(historyResumeMocks.spawnHistoryResume).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: "sess_from_history",
|
||||
normalizedArgs: ["history"],
|
||||
remainingArgs: ["history"],
|
||||
}),
|
||||
);
|
||||
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("propagates the child exit code when resuming from history picker", async () => {
|
||||
historyMocks.runHistoryList.mockImplementationOnce(
|
||||
async () => "sess_from_history",
|
||||
);
|
||||
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(3);
|
||||
process.argv = ["bun", "src/index.ts", "history"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(process.exitCode).toBe(3);
|
||||
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forces chat view when the history-picker child cannot launch", async () => {
|
||||
historyMocks.runHistoryList.mockImplementationOnce(
|
||||
async () => "sess_from_history",
|
||||
);
|
||||
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(undefined);
|
||||
process.argv = ["bun", "src/index.ts", "history"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
+24
-1
@@ -19,6 +19,10 @@ import {
|
||||
buildCliCompactionConfig,
|
||||
CLI_COMPACTION_MODE_EXPECTED_TEXT,
|
||||
} from "./utils/compaction-mode";
|
||||
import {
|
||||
getCliFeatureFlagsService,
|
||||
refreshCliFeatureFlagsInBackground,
|
||||
} from "./utils/feature-flags";
|
||||
import {
|
||||
configureSandboxEnvironment,
|
||||
normalizeAutoApproveArgs,
|
||||
@@ -663,6 +667,21 @@ export async function runCli(): Promise<void> {
|
||||
|
||||
let resumeSessionId: string | undefined = ctx.resumeSessionId;
|
||||
if (resumeSessionId) {
|
||||
// The history picker already created (and tore down) an OpenTUI renderer
|
||||
// in this process; starting the interactive TUI here would create a
|
||||
// second one, which can crash natively during teardown. Resume in a
|
||||
// fresh `cline --id <session-id>` child process instead.
|
||||
const { spawnHistoryResume } = await import("./utils/history-resume");
|
||||
const childExitCode = await spawnHistoryResume({
|
||||
sessionId: resumeSessionId,
|
||||
normalizedArgs,
|
||||
remainingArgs: program.args,
|
||||
configDir,
|
||||
});
|
||||
if (childExitCode !== undefined) {
|
||||
process.exitCode = childExitCode;
|
||||
return;
|
||||
}
|
||||
args = {
|
||||
...args,
|
||||
interactive: true,
|
||||
@@ -836,8 +855,12 @@ export async function runCli(): Promise<void> {
|
||||
};
|
||||
registerDisposable(stopUserInstructionService);
|
||||
try {
|
||||
refreshCliFeatureFlagsInBackground();
|
||||
const lastUsedProviderSettings =
|
||||
providerSettingsManager.getLastUsedProviderSettings();
|
||||
providerSettingsManager.getLastUsedProviderSettings({
|
||||
isClinePassEnabled:
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
|
||||
});
|
||||
const provider = normalizeProviderId(
|
||||
args.provider?.trim() || lastUsedProviderSettings?.provider || "cline",
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type ChatCommandState,
|
||||
createChatCommandHost,
|
||||
chatCommandHost,
|
||||
createChatCommandHost,
|
||||
} from "../../utils/chat-commands";
|
||||
import type { Config } from "../../utils/types";
|
||||
import {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { UserInstructionConfigService } from "@cline/core";
|
||||
@@ -43,9 +50,17 @@ describe("interactive config data loader", () => {
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH =
|
||||
envSnapshot.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
|
||||
if (envSnapshot.CLINE_GLOBAL_SETTINGS_PATH === undefined) {
|
||||
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH =
|
||||
envSnapshot.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
}
|
||||
if (envSnapshot.CLINE_MCP_SETTINGS_PATH === undefined) {
|
||||
delete process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
|
||||
}
|
||||
await Promise.all(
|
||||
tempRoots.map((dir) => rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
@@ -76,6 +91,28 @@ describe("interactive config data loader", () => {
|
||||
return pluginPath;
|
||||
}
|
||||
|
||||
async function writeMcpSettingsPlugin(tempRoot: string): Promise<string> {
|
||||
const pluginsDir = join(tempRoot, ".cline", "plugins");
|
||||
await mkdir(pluginsDir, { recursive: true });
|
||||
const pluginPath = join(pluginsDir, "settings-mcp-plugin.js");
|
||||
await writeFile(
|
||||
pluginPath,
|
||||
[
|
||||
"export default {",
|
||||
" name: 'settings-mcp-plugin',",
|
||||
" manifest: { capabilities: ['mcp'] },",
|
||||
" setup(api) {",
|
||||
" api.registerMcpServer({",
|
||||
" name: 'smoke',",
|
||||
" transport: { type: 'stdio', command: process.execPath, args: ['-e', 'process.exit(0)'] },",
|
||||
" });",
|
||||
" },",
|
||||
"};",
|
||||
].join("\n"),
|
||||
);
|
||||
return pluginPath;
|
||||
}
|
||||
|
||||
it("toggles a skill item to the opposite enabled state and refreshes before reload", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
|
||||
tempRoots.push(tempRoot);
|
||||
@@ -311,6 +348,70 @@ Find installable skills.`,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("loads plugin-owned MCP servers from settings", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
|
||||
tempRoots.push(tempRoot);
|
||||
const settingsPath = join(tempRoot, "cline_mcp_settings.json");
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
|
||||
const pluginPath = await writeMcpSettingsPlugin(tempRoot);
|
||||
await writeFile(
|
||||
settingsPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
smoke: {
|
||||
transport: {
|
||||
type: "stdio",
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.exit(0)"],
|
||||
},
|
||||
metadata: {
|
||||
source: "plugin",
|
||||
pluginName: "settings-mcp-plugin",
|
||||
pluginPath,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
const loader = createInteractiveConfigDataLoader({
|
||||
config: createConfig(tempRoot),
|
||||
});
|
||||
|
||||
const data = await loader.loadConfigData({ includePluginTools: true });
|
||||
|
||||
expect(
|
||||
data.mcp.some(
|
||||
(item) =>
|
||||
item.name === "smoke" &&
|
||||
item.pluginName === "settings-mcp-plugin" &&
|
||||
item.pluginPath === pluginPath &&
|
||||
item.kind === "mcp",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not load plugin MCP rows directly from plugin diagnostics", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
|
||||
tempRoots.push(tempRoot);
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
|
||||
tempRoot,
|
||||
"global-settings.json",
|
||||
);
|
||||
const pluginPath = await writeMcpSettingsPlugin(tempRoot);
|
||||
const loader = createInteractiveConfigDataLoader({
|
||||
config: createConfig(tempRoot),
|
||||
});
|
||||
|
||||
const data = await loader.loadConfigData({ includePluginTools: true });
|
||||
|
||||
expect(data.plugins.some((item) => item.path === pluginPath)).toBe(true);
|
||||
expect(data.mcp.some((item) => item.pluginPath === pluginPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps failed plugins visible with their load error", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
|
||||
tempRoots.push(tempRoot);
|
||||
@@ -731,6 +832,142 @@ Review with the bundled skill.`,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("disables and re-syncs plugin-owned MCP servers when toggling plugins", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
|
||||
tempRoots.push(tempRoot);
|
||||
const settingsPath = join(tempRoot, "cline_mcp_settings.json");
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
|
||||
tempRoot,
|
||||
"global-settings.json",
|
||||
);
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
|
||||
const pluginPath = await writeMcpSettingsPlugin(tempRoot);
|
||||
await writeFile(
|
||||
settingsPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
smoke: {
|
||||
transport: {
|
||||
type: "stdio",
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.exit(0)"],
|
||||
},
|
||||
oauth: {
|
||||
tokens: {
|
||||
access_token: "token",
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
source: "plugin",
|
||||
pluginName: "settings-mcp-plugin",
|
||||
pluginPath,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
const loader = createInteractiveConfigDataLoader({
|
||||
config: createConfig(tempRoot),
|
||||
});
|
||||
const item: InteractiveConfigItem = {
|
||||
id: pluginPath,
|
||||
name: "settings-mcp-plugin",
|
||||
path: pluginPath,
|
||||
enabled: true,
|
||||
source: "workspace-plugin",
|
||||
kind: "plugin",
|
||||
};
|
||||
|
||||
await loader.onToggleConfigItem(item);
|
||||
let settings = JSON.parse(await readFile(settingsPath, "utf8")) as {
|
||||
mcpServers?: Record<
|
||||
string,
|
||||
{ disabled?: boolean; oauth?: { tokens?: Record<string, string> } }
|
||||
>;
|
||||
};
|
||||
expect(settings.mcpServers?.smoke?.disabled).toBe(true);
|
||||
expect(settings.mcpServers?.smoke?.oauth?.tokens?.access_token).toBe(
|
||||
"token",
|
||||
);
|
||||
|
||||
await loader.onToggleConfigItem({ ...item, enabled: false });
|
||||
settings = JSON.parse(await readFile(settingsPath, "utf8")) as {
|
||||
mcpServers?: Record<
|
||||
string,
|
||||
{ disabled?: boolean; oauth?: { tokens?: Record<string, string> } }
|
||||
>;
|
||||
};
|
||||
expect(settings.mcpServers?.smoke?.disabled).toBeUndefined();
|
||||
expect(settings.mcpServers?.smoke?.oauth?.tokens?.access_token).toBe(
|
||||
"token",
|
||||
);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"does not mark plugin disabled when MCP disable write fails",
|
||||
async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
|
||||
tempRoots.push(tempRoot);
|
||||
const settingsPath = join(tempRoot, "cline_mcp_settings.json");
|
||||
const globalSettingsPath = join(tempRoot, "global-settings.json");
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = globalSettingsPath;
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
|
||||
const pluginPath = await writeMcpSettingsPlugin(tempRoot);
|
||||
await writeFile(
|
||||
settingsPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
smoke: {
|
||||
transport: {
|
||||
type: "stdio",
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.exit(0)"],
|
||||
},
|
||||
metadata: {
|
||||
source: "plugin",
|
||||
pluginName: "settings-mcp-plugin",
|
||||
pluginPath,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
await chmod(settingsPath, 0o444);
|
||||
const loader = createInteractiveConfigDataLoader({
|
||||
config: createConfig(tempRoot),
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
loader.onToggleConfigItem({
|
||||
id: pluginPath,
|
||||
name: "settings-mcp-plugin",
|
||||
path: pluginPath,
|
||||
enabled: true,
|
||||
source: "workspace-plugin",
|
||||
kind: "plugin",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await chmod(settingsPath, 0o644);
|
||||
}
|
||||
|
||||
await expect(readFile(globalSettingsPath, "utf8")).rejects.toThrow();
|
||||
const settings = JSON.parse(await readFile(settingsPath, "utf8")) as {
|
||||
mcpServers?: Record<string, { disabled?: boolean }>;
|
||||
};
|
||||
expect(settings.mcpServers?.smoke?.disabled).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("surfaces MCP OAuth status and errors", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
|
||||
tempRoots.push(tempRoot);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
createCoreSettingsService,
|
||||
disablePluginMcpServersInSettings,
|
||||
setDisabledPlugin,
|
||||
setDisabledTools,
|
||||
syncPluginMcpServersToSettings,
|
||||
type UserInstructionConfigService,
|
||||
uninstallPlugin,
|
||||
} from "@cline/core";
|
||||
@@ -70,7 +72,32 @@ export function createInteractiveConfigDataLoader(input: {
|
||||
}
|
||||
|
||||
if (item.kind === "plugin" && typeof item.enabled === "boolean") {
|
||||
setDisabledPlugin(item.path, item.enabled);
|
||||
if (item.enabled) {
|
||||
disablePluginMcpServersInSettings({ pluginPaths: [item.path] });
|
||||
setDisabledPlugin(item.path, true);
|
||||
} else {
|
||||
const ownedMcpMutations = disablePluginMcpServersInSettings({
|
||||
pluginPaths: [item.path],
|
||||
});
|
||||
const result = await syncPluginMcpServersToSettings({
|
||||
pluginPaths: [item.path],
|
||||
cwd: input.config.cwd,
|
||||
workspacePath: workspaceRoot(),
|
||||
providerId: input.config.providerId,
|
||||
modelId: input.config.modelId,
|
||||
});
|
||||
if (ownedMcpMutations.length > 0 && result.failures.length > 0) {
|
||||
throw new Error(
|
||||
`Failed to sync plugin MCP servers: ${result.failures
|
||||
.map((failure) => {
|
||||
const plugin = failure.pluginName ?? failure.pluginPath;
|
||||
return `${plugin}: ${failure.message}`;
|
||||
})
|
||||
.join("; ")}`,
|
||||
);
|
||||
}
|
||||
setDisabledPlugin(item.path, false);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { CliMigrationNotice } from "../kanban-migration/notice";
|
||||
import { logCliError } from "../logging/errors";
|
||||
import {
|
||||
loadClineAccountSnapshot,
|
||||
onProviderChange,
|
||||
switchClineAccount,
|
||||
} from "../tui/cline-account";
|
||||
import type {
|
||||
@@ -611,6 +612,10 @@ export async function runInteractive(
|
||||
},
|
||||
onModelChange: async () => {
|
||||
await sessionRuntime.ensureReady();
|
||||
await onProviderChange({
|
||||
config,
|
||||
providerId: config.providerId,
|
||||
});
|
||||
const existing = providerSettingsManager.getProviderSettings(
|
||||
config.providerId,
|
||||
) ?? {
|
||||
@@ -631,6 +636,16 @@ export async function runInteractive(
|
||||
},
|
||||
onAccountChange: async () => {
|
||||
await sessionRuntime.ensureReady();
|
||||
await loadClineAccountSnapshot({
|
||||
config,
|
||||
clineApiBaseUrl: options?.clineApiBaseUrl,
|
||||
}).catch((error) => {
|
||||
logCliError(
|
||||
config.logger,
|
||||
"Cline account refresh after account change failed",
|
||||
{ error },
|
||||
);
|
||||
});
|
||||
await sessionRuntime.restartWithCurrentMessages();
|
||||
},
|
||||
onResumeSession: async (sessionId: string) => {
|
||||
|
||||
@@ -12,6 +12,8 @@ const createCore = vi.fn();
|
||||
const getCliTelemetryService = vi.fn(() => undefined);
|
||||
const resolveSessionBackend = vi.fn();
|
||||
const listSessionHistoryFromBackend = vi.fn();
|
||||
const featureFlagsPoll = vi.fn(async () => {});
|
||||
const featureFlagsDispose = vi.fn(async () => {});
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
const actual =
|
||||
@@ -49,6 +51,10 @@ describe("createCliCore", () => {
|
||||
listSessionHistoryFromBackend.mockReset();
|
||||
createCore.mockResolvedValue({
|
||||
runtimeAddress: "127.0.0.1:25463",
|
||||
featureFlags: {
|
||||
poll: featureFlagsPoll,
|
||||
dispose: featureFlagsDispose,
|
||||
},
|
||||
start: vi.fn(),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
@@ -68,6 +74,8 @@ describe("createCliCore", () => {
|
||||
delete process.env.CLINE_RPC_ADDRESS;
|
||||
delete process.env.CLINE_SESSION_BACKEND_MODE;
|
||||
delete process.env.CLINE_VCR;
|
||||
featureFlagsPoll.mockClear();
|
||||
featureFlagsDispose.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -108,6 +116,7 @@ describe("createCliCore", () => {
|
||||
backendMode: expect.anything(),
|
||||
}),
|
||||
);
|
||||
expect(featureFlagsPoll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("forces the local backend when requested by the caller", async () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
createCliMessagesArtifactUploader,
|
||||
prepareCliEnterpriseIntegration,
|
||||
} from "../utils/enterprise";
|
||||
import { getCliFeatureFlagsService } from "../utils/feature-flags";
|
||||
import { resolveWorkspaceRoot } from "../utils/helpers";
|
||||
import { getCliTelemetryService } from "../utils/telemetry";
|
||||
import type { ConversationHistory } from "./export";
|
||||
@@ -40,6 +41,11 @@ export async function createCliCore(options?: {
|
||||
const cwd = options?.cwd?.trim() || process.cwd();
|
||||
const workspaceRoot =
|
||||
options?.workspaceRoot?.trim() || resolveWorkspaceRoot(cwd);
|
||||
const telemetry = getCliTelemetryService(options?.logger);
|
||||
const featureFlags = getCliFeatureFlagsService({
|
||||
logger: options?.logger,
|
||||
telemetry,
|
||||
});
|
||||
const core = await ClineCore.create({
|
||||
...(explicitBackendMode ? { backendMode: explicitBackendMode } : {}),
|
||||
...(options?.forceLocalBackend !== true
|
||||
@@ -53,12 +59,18 @@ export async function createCliCore(options?: {
|
||||
}
|
||||
: {}),
|
||||
capabilities: options?.capabilities,
|
||||
telemetry: getCliTelemetryService(options?.logger),
|
||||
telemetry,
|
||||
featureFlags,
|
||||
logger: options?.logger,
|
||||
toolPolicies: options?.toolPolicies,
|
||||
messagesArtifactUploader: createCliMessagesArtifactUploader(),
|
||||
prepare: prepareCliEnterpriseIntegration,
|
||||
});
|
||||
try {
|
||||
await core.featureFlags.poll();
|
||||
} catch (error) {
|
||||
options?.logger?.error?.("Error polling CLI feature flags", { error });
|
||||
}
|
||||
options?.logger?.log("CLI core runtime routing selected", {
|
||||
backendMode: explicitBackendMode ?? "env-managed",
|
||||
rpcAddress: core.runtimeAddress,
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
@@ -9,9 +9,15 @@ const coreMocks = vi.hoisted(() => {
|
||||
return {
|
||||
getProviderSettings: vi.fn(),
|
||||
saveProviderSettings: vi.fn(),
|
||||
fetchMe: vi.fn(),
|
||||
fetchBalance: vi.fn(),
|
||||
fetchOrganizationBalance: vi.fn(),
|
||||
serviceOptions,
|
||||
};
|
||||
});
|
||||
const telemetryMocks = vi.hoisted(() => ({
|
||||
identifyTelemetryAccount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@cline/core")>();
|
||||
@@ -24,6 +30,15 @@ vi.mock("@cline/core", async (importOriginal) => {
|
||||
}) {
|
||||
coreMocks.serviceOptions.push(options);
|
||||
}
|
||||
fetchMe() {
|
||||
return coreMocks.fetchMe();
|
||||
}
|
||||
fetchBalance(userId?: string) {
|
||||
return coreMocks.fetchBalance(userId);
|
||||
}
|
||||
fetchOrganizationBalance(organizationId: string) {
|
||||
return coreMocks.fetchOrganizationBalance(organizationId);
|
||||
}
|
||||
},
|
||||
ProviderSettingsManager: class {
|
||||
getProviderSettings(providerId: string) {
|
||||
@@ -36,6 +51,10 @@ vi.mock("@cline/core", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../utils/telemetry", () => ({
|
||||
identifyTelemetryAccount: telemetryMocks.identifyTelemetryAccount,
|
||||
}));
|
||||
|
||||
function makeConfig(overrides: Partial<Config> = {}): Config {
|
||||
return {
|
||||
providerId: "cline",
|
||||
@@ -78,7 +97,11 @@ describe("createClineAccountService", () => {
|
||||
vi.unstubAllGlobals();
|
||||
coreMocks.getProviderSettings.mockReset();
|
||||
coreMocks.saveProviderSettings.mockReset();
|
||||
coreMocks.fetchMe.mockReset();
|
||||
coreMocks.fetchBalance.mockReset();
|
||||
coreMocks.fetchOrganizationBalance.mockReset();
|
||||
coreMocks.serviceOptions.length = 0;
|
||||
telemetryMocks.identifyTelemetryAccount.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -163,3 +186,66 @@ describe("createClineAccountService", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadClineAccountSnapshot", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
coreMocks.getProviderSettings.mockReset();
|
||||
coreMocks.saveProviderSettings.mockReset();
|
||||
coreMocks.fetchMe.mockReset();
|
||||
coreMocks.fetchBalance.mockReset();
|
||||
coreMocks.fetchOrganizationBalance.mockReset();
|
||||
coreMocks.serviceOptions.length = 0;
|
||||
telemetryMocks.identifyTelemetryAccount.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("identifies the loaded Cline account for telemetry and feature flags", async () => {
|
||||
coreMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "cline",
|
||||
apiKey: "account-token",
|
||||
});
|
||||
const { loadClineAccountSnapshot } = await import("./cline-account");
|
||||
coreMocks.fetchMe.mockResolvedValue({
|
||||
id: "user-1",
|
||||
email: "user@example.com",
|
||||
displayName: "User One",
|
||||
photoUrl: "",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
organizations: [
|
||||
{
|
||||
active: true,
|
||||
memberId: "member-1",
|
||||
name: "Acme",
|
||||
organizationId: "org-1",
|
||||
roles: ["member"],
|
||||
},
|
||||
],
|
||||
});
|
||||
coreMocks.fetchBalance.mockResolvedValue({ balance: 10, userId: "user-1" });
|
||||
coreMocks.fetchOrganizationBalance.mockResolvedValue({
|
||||
balance: 20,
|
||||
organizationId: "org-1",
|
||||
});
|
||||
|
||||
await loadClineAccountSnapshot({ config: makeConfig() });
|
||||
|
||||
expect(telemetryMocks.identifyTelemetryAccount).toHaveBeenCalledWith(
|
||||
{
|
||||
id: "user-1",
|
||||
email: "user@example.com",
|
||||
provider: "cline",
|
||||
organizationId: "org-1",
|
||||
organizationName: "Acme",
|
||||
memberId: "member-1",
|
||||
},
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,12 +14,15 @@ import {
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import { formatCreditBalance, normalizeCreditBalance } from "../utils/output";
|
||||
import { identifyTelemetryAccount } from "../utils/telemetry";
|
||||
import type { Config } from "../utils/types";
|
||||
|
||||
export const CLINE_CREDITS_DASHBOARD_URL =
|
||||
"https://app.cline.bot/dashboard/account?tab=credits";
|
||||
|
||||
type ClineAccountConfig = Pick<Config, "apiKey" | "providerId">;
|
||||
type ClineAccountConfig = Pick<Config, "apiKey" | "logger" | "providerId">;
|
||||
|
||||
const CLINE_PASS_PROVIDER_ID = "cline-pass";
|
||||
|
||||
export interface ClineAccountSnapshot {
|
||||
user: ClineAccountUser;
|
||||
@@ -167,6 +170,15 @@ export async function loadClineAccountSnapshot(input: {
|
||||
const displayedBalance = activeOrganization
|
||||
? (organizationBalance?.balance ?? balance.balance)
|
||||
: balance.balance;
|
||||
const accountContext = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
provider: "cline",
|
||||
organizationId: activeOrganization?.organizationId,
|
||||
organizationName: activeOrganization?.name,
|
||||
memberId: activeOrganization?.memberId,
|
||||
};
|
||||
identifyTelemetryAccount(accountContext, input.config.logger);
|
||||
|
||||
return {
|
||||
user,
|
||||
@@ -190,3 +202,27 @@ export async function switchClineAccount(input: {
|
||||
}
|
||||
await service.switchAccount(input.organizationId);
|
||||
}
|
||||
|
||||
async function onChangeToClinePass(config: ClineAccountConfig) {
|
||||
try {
|
||||
await switchClineAccount({
|
||||
config: config,
|
||||
organizationId: null,
|
||||
});
|
||||
} catch (error) {
|
||||
config.logger?.debug("Failed to switch ClinePass to personal account", {
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function onProviderChange(input: {
|
||||
config: ClineAccountConfig;
|
||||
providerId: string;
|
||||
}): Promise<void> {
|
||||
if (input.providerId === CLINE_PASS_PROVIDER_ID) {
|
||||
return onChangeToClinePass(input.config);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -58,7 +58,11 @@ describe("mcp manager dialog helpers", () => {
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
|
||||
if (envSnapshot.CLINE_MCP_SETTINGS_PATH === undefined) {
|
||||
delete process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
|
||||
}
|
||||
await Promise.all(
|
||||
tempRoots.map((directory) =>
|
||||
rm(directory, { recursive: true, force: true }),
|
||||
@@ -107,6 +111,44 @@ describe("mcp manager dialog helpers", () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not toggle plugin-owned servers", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-mcp-manager-"));
|
||||
tempRoots.push(tempRoot);
|
||||
const settingsPath = join(tempRoot, "cline_mcp_settings.json");
|
||||
await writeFile(
|
||||
settingsPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
docs: {
|
||||
transport: {
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
const result = toggleMcpServer({
|
||||
name: "docs",
|
||||
path: settingsPath,
|
||||
enabled: true,
|
||||
pluginName: "repo-docs",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.message).toContain('managed by plugin "repo-docs"');
|
||||
}
|
||||
expect((await readSettings(settingsPath)).mcpServers?.docs?.disabled).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a visible error message when toggling fails", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-mcp-manager-"));
|
||||
tempRoots.push(tempRoot);
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface McpEntry {
|
||||
enabled?: boolean;
|
||||
description?: string;
|
||||
lastError?: string;
|
||||
pluginName?: string;
|
||||
}
|
||||
|
||||
export type McpServerToggleResult =
|
||||
@@ -36,6 +37,12 @@ export function getMcpManagerEntryStatus(
|
||||
}
|
||||
|
||||
export function toggleMcpServer(server: McpEntry): McpServerToggleResult {
|
||||
if (server.pluginName) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `MCP server "${server.name}" is managed by plugin "${server.pluginName}". Disable the plugin to disable this server.`,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const currentlyEnabled = server.enabled !== false;
|
||||
setMcpServerDisabled({
|
||||
@@ -71,6 +78,7 @@ export function McpManagerContent(
|
||||
const settingsPath = servers[0]?.path ?? resolveDefaultMcpSettingsPath();
|
||||
const itemCount = servers.length;
|
||||
const selectedServer = servers[selected];
|
||||
const hasPluginOwnedServers = servers.some((server) => server.pluginName);
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
@@ -150,6 +158,7 @@ export function McpManagerContent(
|
||||
{isSel ? "\u25b8 " : " "}
|
||||
{enabledIcon}
|
||||
{srv.name}
|
||||
{srv.pluginName ? " *" : ""}
|
||||
</text>
|
||||
{status && (
|
||||
<text fg={srv.lastError ? palette.error : "gray"}>
|
||||
@@ -184,6 +193,12 @@ export function McpManagerContent(
|
||||
</box>
|
||||
)}
|
||||
|
||||
{hasPluginOwnedServers && (
|
||||
<text fg="gray" marginTop={1}>
|
||||
* managed by plugin; disable the plugin to disable the server.
|
||||
</text>
|
||||
)}
|
||||
|
||||
<text fg="gray" marginTop={1}>
|
||||
<em>{getMcpManagerFooterText(servers.length > 0)}</em>
|
||||
</text>
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
completeClineDeviceAuth,
|
||||
getProviderConfigFields,
|
||||
isOAuthProvider,
|
||||
listLocalProviders,
|
||||
loginLocalProvider,
|
||||
type ProviderConfigFieldKey,
|
||||
type ProviderConfigFieldRequirement,
|
||||
@@ -22,6 +21,7 @@ import {
|
||||
checkCodexCliInstalled,
|
||||
isOpenAICodexCliProvider,
|
||||
} from "../../../utils/codex-cli";
|
||||
import { listLocalProviders } from "../../../utils/provider-catalog";
|
||||
import { palette } from "../../palette";
|
||||
import {
|
||||
getDefaultAwsRegion,
|
||||
|
||||
@@ -17,7 +17,9 @@ export async function renderHistoryStandalone(input: {
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let result: number | string = 0;
|
||||
let resolved = false;
|
||||
let destroyStarted = false;
|
||||
let unmounted = false;
|
||||
const root = createRoot(renderer);
|
||||
|
||||
@@ -29,24 +31,29 @@ export async function renderHistoryStandalone(input: {
|
||||
root.unmount();
|
||||
};
|
||||
|
||||
const settle = (value: number | string) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
unmountRoot();
|
||||
renderer.destroy();
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
// Resolve only once teardown has finished, so callers never run while
|
||||
// the renderer is still restoring the terminal.
|
||||
renderer.on("destroy", () => {
|
||||
unmountRoot();
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve(0);
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
|
||||
const settle = (value: number | string) => {
|
||||
if (destroyStarted) {
|
||||
return;
|
||||
}
|
||||
destroyStarted = true;
|
||||
result = value;
|
||||
unmountRoot();
|
||||
// Let OpenTUI finish parsing the current stdin batch before teardown.
|
||||
queueMicrotask(() => {
|
||||
renderer.destroy();
|
||||
});
|
||||
};
|
||||
|
||||
root.render(
|
||||
React.createElement(HistoryStandaloneContent, {
|
||||
rows: input.rows,
|
||||
|
||||
@@ -17,6 +17,7 @@ function toMcpEntries(items: InteractiveConfigItem[]): McpEntry[] {
|
||||
enabled: item.enabled,
|
||||
description: item.description,
|
||||
lastError: item.loadError,
|
||||
pluginName: item.pluginName,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -179,7 +179,6 @@ async function runProviderChange(
|
||||
|
||||
config.providerId = newProviderId;
|
||||
config.apiKey = newApiKey;
|
||||
|
||||
const resolved = await resolveProviderConfig(
|
||||
newProviderId,
|
||||
{
|
||||
|
||||
@@ -86,6 +86,7 @@ export interface InteractiveConfigData {
|
||||
mcp: InteractiveConfigItem[];
|
||||
tools: InteractiveConfigItem[];
|
||||
workflowSlashCommands: InteractiveSlashCommand[];
|
||||
pluginDiagnosticsLoaded?: boolean;
|
||||
}
|
||||
|
||||
export interface LoadInteractiveConfigDataOptions {
|
||||
@@ -93,12 +94,14 @@ export interface LoadInteractiveConfigDataOptions {
|
||||
}
|
||||
|
||||
export function isToggleableInteractiveConfigItem(
|
||||
item: Pick<InteractiveConfigItem, "kind" | "source">,
|
||||
item: Pick<InteractiveConfigItem, "kind" | "source" | "pluginName">,
|
||||
): boolean {
|
||||
if (item.kind === "mcp") {
|
||||
return !item.pluginName;
|
||||
}
|
||||
return (
|
||||
item.kind === "skill" ||
|
||||
item.kind === "plugin" ||
|
||||
item.kind === "mcp" ||
|
||||
item.source === "builtin" ||
|
||||
item.source === "workspace-plugin" ||
|
||||
item.source === "global-plugin"
|
||||
@@ -242,9 +245,10 @@ function readPackageName(packageJsonPath: string): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function getPluginDisplayName(filePath: string): string {
|
||||
function getPluginDisplayName(filePath: string, searchRoot: string): string {
|
||||
let current = dirname(filePath);
|
||||
for (let depth = 0; depth < 4; depth++) {
|
||||
const root = resolve(searchRoot);
|
||||
while (isPathWithin(root, current)) {
|
||||
const packageJsonPath = join(current, "package.json");
|
||||
if (existsSync(packageJsonPath)) {
|
||||
const packageName = readPackageName(packageJsonPath);
|
||||
@@ -384,7 +388,7 @@ export async function loadInteractiveConfigData(input: {
|
||||
for (const filePath of discoverPluginModulePaths(directory)) {
|
||||
plugins.push({
|
||||
id: filePath,
|
||||
name: getPluginDisplayName(filePath),
|
||||
name: getPluginDisplayName(filePath, directory),
|
||||
path: filePath,
|
||||
enabled: !disabledPlugins.has(filePath),
|
||||
kind: "plugin",
|
||||
@@ -458,6 +462,16 @@ export async function loadInteractiveConfigData(input: {
|
||||
for (const registration of resolveMcpServerRegistrations({
|
||||
filePath: mcpSettingsPath,
|
||||
})) {
|
||||
const pluginName =
|
||||
registration.metadata?.source === "plugin" &&
|
||||
typeof registration.metadata.pluginName === "string"
|
||||
? registration.metadata.pluginName
|
||||
: undefined;
|
||||
const pluginPath =
|
||||
registration.metadata?.source === "plugin" &&
|
||||
typeof registration.metadata.pluginPath === "string"
|
||||
? registration.metadata.pluginPath
|
||||
: undefined;
|
||||
mcp.push({
|
||||
id: registration.name,
|
||||
name: registration.name,
|
||||
@@ -467,6 +481,8 @@ export async function loadInteractiveConfigData(input: {
|
||||
source: detectSource(mcpSettingsPath, input.workspaceRoot),
|
||||
description: getMcpDescription(registration),
|
||||
loadError: registration.oauth?.lastError,
|
||||
pluginName,
|
||||
pluginPath,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
@@ -514,6 +530,7 @@ export async function loadInteractiveConfigData(input: {
|
||||
toolNames: [pluginTool.name],
|
||||
configKind: "tool",
|
||||
pluginName: pluginTool.pluginName,
|
||||
pluginPath: pluginTool.path,
|
||||
source: pluginTool.source,
|
||||
description: pluginTool.description,
|
||||
});
|
||||
@@ -533,5 +550,6 @@ export async function loadInteractiveConfigData(input: {
|
||||
mcp: toSorted(mcp.filter((item) => existsSync(item.path))),
|
||||
tools: toSorted(tools),
|
||||
workflowSlashCommands,
|
||||
pluginDiagnosticsLoaded: input.includePluginTools !== false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -921,6 +921,7 @@ function App(props: TuiProps) {
|
||||
if (result.reasoningEffort !== undefined) {
|
||||
props.config.reasoningEffort = result.reasoningEffort;
|
||||
}
|
||||
|
||||
handleModelChange().then(() => setAppView("home"));
|
||||
}}
|
||||
onExit={() => {
|
||||
|
||||
@@ -229,3 +229,15 @@ export function getConfigFooterText({
|
||||
export function getConfigItemDisplayName(name: string): string {
|
||||
return name;
|
||||
}
|
||||
|
||||
export function getPluginDiagnosticsLoadingText(
|
||||
tab: InteractiveConfigTab,
|
||||
): string | undefined {
|
||||
if (tab === "tools") {
|
||||
return "Loading plugin tools...";
|
||||
}
|
||||
if (tab === "plugins") {
|
||||
return "Loading plugin diagnostics...";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,18 @@ describe("config view helpers", () => {
|
||||
expect(isToggleableConfigItem(createItem({ kind: "mcp" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat plugin MCP rows as toggleable", () => {
|
||||
expect(
|
||||
isToggleableConfigItem(
|
||||
createItem({
|
||||
kind: "mcp",
|
||||
pluginName: "plugin",
|
||||
source: "workspace-plugin",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves Enter/Tab on a skill row to details", () => {
|
||||
const skill = createItem({
|
||||
kind: "skill",
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
getConfigFooterText,
|
||||
getConfigItemDisplayName,
|
||||
getConfigTabs,
|
||||
getPluginDiagnosticsLoadingText,
|
||||
isInlineConfigAction,
|
||||
isToggleableConfigItem,
|
||||
resolveActiveConfigItems,
|
||||
@@ -198,6 +199,7 @@ function appendToolGroupRows(
|
||||
rightLabel: `${enabledCount}/${groupItems.length} tools enabled`,
|
||||
indent: 2,
|
||||
});
|
||||
|
||||
for (const item of sortBySourceThenName(groupItems)) {
|
||||
rows.push({
|
||||
kind: "ext",
|
||||
@@ -245,17 +247,24 @@ function appendToolRows(
|
||||
appendExtRows(rows, builtinTools);
|
||||
}
|
||||
|
||||
const pluginGroups = groupToolItems(items.filter((item) => item.pluginName));
|
||||
const pluginToolItems = items.filter((item) => item.pluginName);
|
||||
const pluginGroups = groupToolItems(pluginToolItems);
|
||||
if (pluginGroups.length > 0) {
|
||||
rows.push({ kind: "head", label: "Plugins" });
|
||||
appendToolGroupRows(
|
||||
rows,
|
||||
pluginGroups,
|
||||
getSharedToolNames(items.filter((item) => item.pluginName)),
|
||||
getSharedToolNames(pluginToolItems),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function hasPluginDiagnostics(data: InteractiveConfigData): boolean {
|
||||
return (
|
||||
data.pluginDiagnosticsLoaded || data.tools.some((item) => item.pluginName)
|
||||
);
|
||||
}
|
||||
|
||||
function appendSkillRows(
|
||||
rows: ConfigRow[],
|
||||
items: InteractiveConfigItem[],
|
||||
@@ -305,11 +314,18 @@ function withOptimisticToggle(
|
||||
).filter(Boolean),
|
||||
);
|
||||
const updateItems = (items: InteractiveConfigItem[]) =>
|
||||
items.map((candidate) =>
|
||||
matchesItem(candidate)
|
||||
? { ...candidate, enabled: nextEnabled }
|
||||
: candidate,
|
||||
);
|
||||
items.map((candidate) => {
|
||||
if (matchesItem(candidate)) {
|
||||
return { ...candidate, enabled: nextEnabled };
|
||||
}
|
||||
if (
|
||||
item.kind === "plugin" &&
|
||||
(candidate.path === item.path || candidate.pluginPath === item.path)
|
||||
) {
|
||||
return { ...candidate, enabled: nextEnabled };
|
||||
}
|
||||
return candidate;
|
||||
});
|
||||
const updateTools = (items: InteractiveConfigItem[]) =>
|
||||
items.map((candidate) => {
|
||||
if (matchesItem(candidate)) {
|
||||
@@ -381,7 +397,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
);
|
||||
const [configData, setConfigData] = useState(props.configData);
|
||||
const [pluginToolsLoaded, setPluginToolsLoaded] = useState(
|
||||
props.configData.tools.some((item) => item.pluginName),
|
||||
hasPluginDiagnostics(props.configData),
|
||||
);
|
||||
const [pluginToolsLoading, setPluginToolsLoading] = useState(false);
|
||||
const [pluginToolsError, setPluginToolsError] = useState<
|
||||
@@ -465,10 +481,11 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
});
|
||||
} else if (activeTab === "tools") {
|
||||
appendToolRows(r, activeItems);
|
||||
if (pluginToolsLoading) {
|
||||
const loadingText = getPluginDiagnosticsLoadingText(activeTab);
|
||||
if (pluginToolsLoading && loadingText) {
|
||||
r.push({
|
||||
kind: "detail",
|
||||
text: "Loading plugin tools...",
|
||||
text: loadingText,
|
||||
});
|
||||
}
|
||||
if (pluginToolsError) {
|
||||
@@ -499,9 +516,10 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
});
|
||||
}
|
||||
if (activeTab === "plugins" && pluginToolsLoading) {
|
||||
const loadingText = getPluginDiagnosticsLoadingText(activeTab);
|
||||
r.push({
|
||||
kind: "detail",
|
||||
text: "Loading plugin diagnostics...",
|
||||
text: loadingText ?? "Loading plugin diagnostics...",
|
||||
});
|
||||
}
|
||||
if (activeTab === "plugins" && pluginToolsError) {
|
||||
@@ -549,15 +567,13 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
});
|
||||
if (nextData) {
|
||||
setConfigData(nextData);
|
||||
setPluginToolsLoaded(nextData.tools.some((tool) => tool.pluginName));
|
||||
setPluginToolsLoaded(hasPluginDiagnostics(nextData));
|
||||
} else if (item.kind === "plugin" && loadConfigData) {
|
||||
const refreshedData = await loadConfigData({
|
||||
includePluginTools: true,
|
||||
});
|
||||
setConfigData(refreshedData);
|
||||
setPluginToolsLoaded(
|
||||
refreshedData.tools.some((tool) => tool.pluginName),
|
||||
);
|
||||
setPluginToolsLoaded(hasPluginDiagnostics(refreshedData));
|
||||
setPluginToolsError(undefined);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
captureProviderConfigured,
|
||||
getLocalProviderModels,
|
||||
getProviderConfigFields,
|
||||
listLocalProviders,
|
||||
type ProviderConfigFieldKey,
|
||||
type ProviderConfigFields,
|
||||
ProviderSettingsManager,
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
isOpenAICodexCliProvider,
|
||||
} from "../../../utils/codex-cli";
|
||||
import { getPersistedProviderApiKey } from "../../../utils/provider-auth";
|
||||
import { listLocalProviders } from "../../../utils/provider-catalog";
|
||||
import { getCliTelemetryService } from "../../../utils/telemetry";
|
||||
import {
|
||||
buildClineModelEntries,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
disposeCliFeatureFlagsService,
|
||||
getCliFeatureFlagsService,
|
||||
} from "./feature-flags";
|
||||
|
||||
describe("CLI feature flags singleton", () => {
|
||||
afterEach(async () => {
|
||||
await disposeCliFeatureFlagsService();
|
||||
});
|
||||
|
||||
it("recreates the singleton after disposal", async () => {
|
||||
const service = getCliFeatureFlagsService();
|
||||
|
||||
await disposeCliFeatureFlagsService();
|
||||
|
||||
expect(getCliFeatureFlagsService()).not.toBe(service);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
type BasicLogger,
|
||||
type FeatureFlagsContext,
|
||||
FeatureFlagsService,
|
||||
type ITelemetryService,
|
||||
NoOpFeatureFlagsProvider,
|
||||
registerDisposable,
|
||||
resolveCoreDistinctId,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
buildClinePostHogClient,
|
||||
PostHogFeatureFlagsProvider,
|
||||
} from "@cline/core/services/feature-flags/posthog";
|
||||
import { resolveClineDataDir } from "@cline/shared/storage";
|
||||
|
||||
let cliFeatureFlagsContext: FeatureFlagsContext = { clientName: "cline-cli" };
|
||||
let cliFeatureFlagsService: FeatureFlagsService | undefined;
|
||||
|
||||
const CLI_FEATURE_FLAGS_CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function resolveCliFeatureFlagsCachePath(): string {
|
||||
return join(resolveClineDataDir(), "cache", "feature-flags.json");
|
||||
}
|
||||
|
||||
function ensureCliDistinctId(): string {
|
||||
const distinctId = cliFeatureFlagsContext.distinctId?.trim();
|
||||
if (distinctId) {
|
||||
return distinctId;
|
||||
}
|
||||
const resolved = resolveCoreDistinctId();
|
||||
cliFeatureFlagsContext.distinctId = resolved;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function getCliFeatureFlagsContext(): FeatureFlagsContext {
|
||||
ensureCliDistinctId();
|
||||
return { ...cliFeatureFlagsContext };
|
||||
}
|
||||
|
||||
export function getCliFeatureFlagsService(options?: {
|
||||
logger?: BasicLogger;
|
||||
telemetry?: ITelemetryService;
|
||||
}): FeatureFlagsService {
|
||||
if (!cliFeatureFlagsService) {
|
||||
const apiKey = process.env.TELEMETRY_SERVICE_API_KEY;
|
||||
const provider =
|
||||
apiKey &&
|
||||
process.env.IS_TEST !== "true" &&
|
||||
process.env.E2E_TEST !== "true"
|
||||
? new PostHogFeatureFlagsProvider({
|
||||
client: buildClinePostHogClient(apiKey),
|
||||
config: {
|
||||
logger: options?.logger,
|
||||
},
|
||||
})
|
||||
: new NoOpFeatureFlagsProvider();
|
||||
|
||||
cliFeatureFlagsService = new FeatureFlagsService({
|
||||
provider,
|
||||
telemetry: options?.telemetry,
|
||||
logger: options?.logger,
|
||||
context: getCliFeatureFlagsContext(),
|
||||
cacheFilePath: resolveCliFeatureFlagsCachePath(),
|
||||
persistentCacheMaxAgeMs: CLI_FEATURE_FLAGS_CACHE_MAX_AGE_MS,
|
||||
});
|
||||
registerDisposable(disposeCliFeatureFlagsService);
|
||||
}
|
||||
|
||||
return cliFeatureFlagsService;
|
||||
}
|
||||
|
||||
export function refreshCliFeatureFlagsInBackground(logger?: BasicLogger): void {
|
||||
const service = getCliFeatureFlagsService({ logger });
|
||||
void service.poll().catch((error) => {
|
||||
logger?.error?.("Error refreshing CLI feature flags", { error });
|
||||
});
|
||||
}
|
||||
|
||||
export async function disposeCliFeatureFlagsService(): Promise<void> {
|
||||
if (!cliFeatureFlagsService) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = cliFeatureFlagsService;
|
||||
cliFeatureFlagsService = undefined;
|
||||
await current.dispose();
|
||||
}
|
||||
|
||||
export async function identifyFeatureFlagsAccount(
|
||||
account: { id?: string; email?: string },
|
||||
logger?: BasicLogger,
|
||||
): Promise<void> {
|
||||
const accountId = account.id?.trim();
|
||||
cliFeatureFlagsContext = {
|
||||
...cliFeatureFlagsContext,
|
||||
...(accountId ? { distinctId: accountId, userId: accountId } : {}),
|
||||
...(account.email?.trim() ? { email: account.email.trim() } : {}),
|
||||
};
|
||||
|
||||
if (!cliFeatureFlagsService) {
|
||||
return;
|
||||
}
|
||||
|
||||
cliFeatureFlagsService.setContext(getCliFeatureFlagsContext());
|
||||
try {
|
||||
await cliFeatureFlagsService.poll();
|
||||
} catch (error) {
|
||||
logger?.error?.("Error polling CLI feature flags", { error });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildHistoryResumeArgs } from "./history-resume";
|
||||
|
||||
describe("buildHistoryResumeArgs", () => {
|
||||
it("replaces the history subcommand with --id", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["history"],
|
||||
remainingArgs: ["history"],
|
||||
}),
|
||||
).toEqual(["--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("preserves global flags that precede the subcommand", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: [
|
||||
"--data-dir",
|
||||
"/tmp/data",
|
||||
"-m",
|
||||
"claude-sonnet-4-6",
|
||||
"history",
|
||||
"--limit",
|
||||
"5",
|
||||
],
|
||||
remainingArgs: ["history", "--limit", "5"],
|
||||
}),
|
||||
).toEqual([
|
||||
"--data-dir",
|
||||
"/tmp/data",
|
||||
"-m",
|
||||
"claude-sonnet-4-6",
|
||||
"--id",
|
||||
"sess_1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a global flag value that matches the subcommand alias", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["-m", "h", "h"],
|
||||
remainingArgs: ["h"],
|
||||
}),
|
||||
).toEqual(["-m", "h", "--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("forwards a config dir passed as a subcommand option", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["history", "--config", "/tmp/conf"],
|
||||
remainingArgs: ["history", "--config", "/tmp/conf"],
|
||||
configDir: "/tmp/conf",
|
||||
}),
|
||||
).toEqual(["--config", "/tmp/conf", "--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("does not duplicate a config dir already in the global flags", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["--config", "/tmp/conf", "history"],
|
||||
remainingArgs: ["history"],
|
||||
configDir: "/tmp/conf",
|
||||
}),
|
||||
).toEqual(["--config", "/tmp/conf", "--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("recognizes the --config=<dir> spelling in global flags", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["--config=/tmp/conf", "history"],
|
||||
remainingArgs: ["history"],
|
||||
configDir: "/tmp/conf",
|
||||
}),
|
||||
).toEqual(["--config=/tmp/conf", "--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("returns undefined when remaining args are not a suffix of argv", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["history", "--limit", "5"],
|
||||
remainingArgs: ["history", "--limit", "9"],
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["history"],
|
||||
remainingArgs: ["extra", "history"],
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { resolveCliLaunchSpec } from "./internal-launch";
|
||||
|
||||
export interface HistoryResumeCommand {
|
||||
launcher: string;
|
||||
childArgs: string[];
|
||||
}
|
||||
|
||||
export interface BuildHistoryResumeArgsInput {
|
||||
sessionId: string;
|
||||
/** Full normalized CLI args (process.argv.slice(2) after normalization). */
|
||||
normalizedArgs: string[];
|
||||
/**
|
||||
* Commander's `program.args` after parsing: the `history` subcommand token
|
||||
* and everything following it. Must be a suffix of `normalizedArgs`.
|
||||
*/
|
||||
remainingArgs: string[];
|
||||
/**
|
||||
* Config dir resolved from the full argv. Forwarded explicitly because
|
||||
* `--config` may have been passed as a `history` subcommand option, which
|
||||
* would otherwise be dropped with the rest of the subcommand args.
|
||||
*/
|
||||
configDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds argv for relaunching the CLI as `cline <globalFlags> --id <sessionId>`
|
||||
* after a session is picked in `cline history`. Returns undefined when the
|
||||
* global-flag prefix cannot be derived safely (caller falls back to resuming
|
||||
* in-process).
|
||||
*/
|
||||
export function buildHistoryResumeArgs(
|
||||
input: BuildHistoryResumeArgsInput,
|
||||
): string[] | undefined {
|
||||
const { sessionId, normalizedArgs, remainingArgs, configDir } = input;
|
||||
const splitIndex = normalizedArgs.length - remainingArgs.length;
|
||||
if (splitIndex < 0) {
|
||||
return undefined;
|
||||
}
|
||||
for (let i = 0; i < remainingArgs.length; i++) {
|
||||
if (normalizedArgs[splitIndex + i] !== remainingArgs[i]) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
const globalArgs = normalizedArgs.slice(0, splitIndex);
|
||||
const args = [...globalArgs];
|
||||
const hasConfigFlag = globalArgs.some(
|
||||
(arg) => arg === "--config" || arg.startsWith("--config="),
|
||||
);
|
||||
if (configDir && !hasConfigFlag) {
|
||||
args.push("--config", configDir);
|
||||
}
|
||||
args.push("--id", sessionId);
|
||||
return args;
|
||||
}
|
||||
|
||||
export function buildHistoryResumeCommand(
|
||||
input: BuildHistoryResumeArgsInput,
|
||||
): HistoryResumeCommand | undefined {
|
||||
const childArgs = buildHistoryResumeArgs(input);
|
||||
if (!childArgs) {
|
||||
return undefined;
|
||||
}
|
||||
const spec = resolveCliLaunchSpec();
|
||||
if (!spec) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
launcher: spec.launcher,
|
||||
childArgs: [...spec.childArgsPrefix, ...childArgs],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumes a history-picked session in a fresh `cline --id <sessionId>` child
|
||||
* process with inherited stdio, and returns its exit code. Creating a second
|
||||
* OpenTUI renderer in the picker's process can crash natively during teardown
|
||||
* (Bun "panic(main thread): Segmentation fault" on Ctrl+C), so the resumed
|
||||
* interactive TUI must get a process of its own.
|
||||
*
|
||||
* Returns undefined when the child cannot be launched; the caller should fall
|
||||
* back to resuming in-process.
|
||||
*/
|
||||
export async function spawnHistoryResume(
|
||||
input: BuildHistoryResumeArgsInput,
|
||||
): Promise<number | undefined> {
|
||||
const command = buildHistoryResumeCommand(input);
|
||||
if (!command) {
|
||||
return undefined;
|
||||
}
|
||||
const { spawn } = await import("node:child_process");
|
||||
return await new Promise<number | undefined>((resolve) => {
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawn(command.launcher, command.childArgs, {
|
||||
stdio: "inherit",
|
||||
});
|
||||
} catch {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
// The child shares this foreground process group, so terminal-generated
|
||||
// Ctrl+C already reaches it. Keep the parent alive to reap the child
|
||||
// without re-forwarding a second signal into the TUI teardown path.
|
||||
const suppressParentSignal = () => {};
|
||||
process.on("SIGINT", suppressParentSignal);
|
||||
process.on("SIGTERM", suppressParentSignal);
|
||||
const finish = (value: number | undefined) => {
|
||||
process.off("SIGINT", suppressParentSignal);
|
||||
process.off("SIGTERM", suppressParentSignal);
|
||||
resolve(value);
|
||||
};
|
||||
child.once("error", () => finish(undefined));
|
||||
child.once("exit", (code, signal) => finish(signal ? 1 : (code ?? 0)));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listLocalProviders: vi.fn(async () => ({ providers: [], settingsPath: "" })),
|
||||
getBooleanFlagEnabled: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@cline/core")>();
|
||||
return {
|
||||
...actual,
|
||||
listLocalProviders: mocks.listLocalProviders,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./feature-flags", () => ({
|
||||
getCliFeatureFlagsService: () => ({
|
||||
getBooleanFlagEnabled: mocks.getBooleanFlagEnabled,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("listLocalProviders", () => {
|
||||
it("passes the ClinePass feature flag into the SDK provider list", async () => {
|
||||
const { listLocalProviders } = await import("./provider-catalog");
|
||||
const manager = {} as never;
|
||||
|
||||
await listLocalProviders(manager);
|
||||
|
||||
expect(mocks.getBooleanFlagEnabled).toHaveBeenCalledWith("ext-cline-pass");
|
||||
expect(mocks.listLocalProviders).toHaveBeenCalledWith(manager, {
|
||||
isClinePassEnabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import {
|
||||
listLocalProviders as internalListLocalProviders,
|
||||
type ProviderSettingsManager,
|
||||
} from "@cline/core";
|
||||
import { getCliFeatureFlagsService } from "./feature-flags";
|
||||
|
||||
export async function listLocalProviders(
|
||||
manager: ProviderSettingsManager,
|
||||
): ReturnType<typeof internalListLocalProviders> {
|
||||
return await internalListLocalProviders(manager, {
|
||||
isClinePassEnabled:
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
|
||||
});
|
||||
}
|
||||
@@ -34,7 +34,7 @@ vi.mock("./telemetry", async (importOriginal) => {
|
||||
|
||||
import {
|
||||
captureCliExtensionActivated,
|
||||
identifyCliTelemetryAccount,
|
||||
identifyTelemetryAccount,
|
||||
} from "./telemetry";
|
||||
import { resetCliExtensionActivationForTests } from "./telemetry.test-helpers";
|
||||
|
||||
@@ -93,7 +93,7 @@ describe("captureCliExtensionActivated", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("identifyCliTelemetryAccount", () => {
|
||||
describe("identifyTelemetryAccount", () => {
|
||||
beforeEach(() => {
|
||||
hoisted.identifyAccount.mockClear();
|
||||
hoisted.getCliTelemetryService.mockClear();
|
||||
@@ -107,7 +107,7 @@ describe("identifyCliTelemetryAccount", () => {
|
||||
memberId: "member-7",
|
||||
provider: "cline",
|
||||
};
|
||||
identifyCliTelemetryAccount(account);
|
||||
identifyTelemetryAccount(account);
|
||||
expect(hoisted.identifyAccount).toHaveBeenCalledWith(undefined, account);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
TelemetryLoggerSink,
|
||||
} from "@cline/core";
|
||||
import { getCliBuildInfo } from "./common";
|
||||
import { identifyFeatureFlagsAccount } from "./feature-flags";
|
||||
import {
|
||||
markActivationCaptured,
|
||||
wasActivationCaptured,
|
||||
@@ -102,11 +103,12 @@ export interface CliTelemetryAccountContext {
|
||||
* Safe to call multiple times; the latest values win, mirroring the legacy
|
||||
* singleton-based behavior.
|
||||
*/
|
||||
export function identifyCliTelemetryAccount(
|
||||
export function identifyTelemetryAccount(
|
||||
account: CliTelemetryAccountContext,
|
||||
logger?: BasicLogger,
|
||||
): void {
|
||||
identifyAccount(getCliTelemetryService(logger), account);
|
||||
void identifyFeatureFlagsAccount(account, logger);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,6 +136,7 @@ export function captureCliExtensionActivated(
|
||||
const telemetry = getCliTelemetryService(logger);
|
||||
if (account) {
|
||||
identifyAccount(telemetry, account);
|
||||
void identifyFeatureFlagsAccount(account, logger);
|
||||
}
|
||||
captureExtensionActivated(telemetry);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as p from "@clack/prompts";
|
||||
import { authorizeMcpServerOAuth } from "@cline/core";
|
||||
import open from "open";
|
||||
import { authorizeMcpServerOAuthWithBrowser as authorizeOAuth } from "./oauth";
|
||||
import {
|
||||
addServer,
|
||||
clearServerOAuth,
|
||||
@@ -17,16 +16,6 @@ function isCancel(value: unknown): value is symbol {
|
||||
return p.isCancel(value);
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.trim();
|
||||
if (message.length > 0) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function transportLabel(t: McpTransport): string {
|
||||
if (t.type === "stdio") return `stdio: ${t.command}`;
|
||||
return `${t.type}: ${t.url}`;
|
||||
@@ -222,29 +211,6 @@ async function collectUrlTransport(
|
||||
};
|
||||
}
|
||||
|
||||
async function authorizeOAuth(name: string): Promise<void> {
|
||||
p.log.info("Opening browser for MCP OAuth authorization");
|
||||
try {
|
||||
const result = await authorizeMcpServerOAuth({
|
||||
serverName: name,
|
||||
filePath: getSettingsPath(),
|
||||
openUrl: async (url) => {
|
||||
p.log.message(`Authorization URL: ${url}`);
|
||||
await open(url, { wait: false });
|
||||
},
|
||||
onServerListening: (info) => {
|
||||
p.log.message(`Waiting for OAuth callback at ${info.callbackUrl}`);
|
||||
},
|
||||
});
|
||||
p.log.success(result.message);
|
||||
} catch (error) {
|
||||
p.log.error(`OAuth authorization failed: ${toErrorMessage(error)}`);
|
||||
p.log.warn(
|
||||
`Server "${name}" is still saved. Choose "Authorize OAuth" to retry.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function actionAdd(): Promise<void> {
|
||||
const name = await p.text({
|
||||
message: "Server name",
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as p from "@clack/prompts";
|
||||
import {
|
||||
authorizeMcpServerOAuth,
|
||||
resolveDefaultMcpSettingsPath,
|
||||
} from "@cline/core";
|
||||
import open from "open";
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.trim();
|
||||
if (message.length > 0) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export async function authorizeMcpServerOAuthWithBrowser(
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
p.log.info("Opening browser for MCP OAuth authorization");
|
||||
try {
|
||||
const result = await authorizeMcpServerOAuth({
|
||||
serverName: name,
|
||||
filePath: resolveDefaultMcpSettingsPath(),
|
||||
openUrl: async (url) => {
|
||||
p.log.message(`Authorization URL: ${url}`);
|
||||
await open(url, { wait: false });
|
||||
},
|
||||
onServerListening: (info) => {
|
||||
p.log.message(`Waiting for OAuth callback at ${info.callbackUrl}`);
|
||||
},
|
||||
});
|
||||
p.log.success(result.message);
|
||||
} catch (error) {
|
||||
p.log.error(`OAuth authorization failed: ${toErrorMessage(error)}`);
|
||||
p.log.warn(
|
||||
`Server "${name}" is still saved. Choose "Authorize OAuth" to retry.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,13 @@
|
||||
"src/**/__tests__/*.ts",
|
||||
"src/test/services/**/*.test.ts"
|
||||
],
|
||||
"ignore": [
|
||||
"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"
|
||||
],
|
||||
"require": [
|
||||
"ts-node/register",
|
||||
"source-map-support/register",
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
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",
|
||||
],
|
||||
mocha: {
|
||||
ui: "bdd",
|
||||
timeout: 20000, // Maximum time (in ms) that a test can run before failing
|
||||
|
||||
+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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
+3883
-3662
File diff suppressed because it is too large
Load Diff
+30
-61
@@ -2,7 +2,7 @@
|
||||
"name": "claude-dev",
|
||||
"displayName": "Cline",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "3.89.2",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -395,21 +360,28 @@
|
||||
"clean:all": "npm run clean:build && npm 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",
|
||||
"check-types": "npm run protos && npx tsc --noEmit && cd webview-ui && npx tsc --noEmit",
|
||||
"lint": "biome lint --config-path ./biome.jsonc --no-errors-on-unmatched --files-ignore-unknown=true --diagnostic-level=error && npm 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",
|
||||
"analyze:unused": "npx --yes knip --config knip.json --include files,exports,types,enumMembers,duplicates",
|
||||
"analyze:unused:prod": "npx --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": "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:integration": "npm run compile-tests && vscode-test",
|
||||
"test:unit": "cross-env TS_NODE_PROJECT=./tsconfig.unit-test.json mocha",
|
||||
"test:coverage": "vscode-test --coverage",
|
||||
"test:vitest": "vitest run --config vitest.config.ts",
|
||||
"test:vitest:watch": "vitest --config vitest.config.ts",
|
||||
"test:coverage": "npm run compile-tests && 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",
|
||||
"dev:mcp-oauth-test-server": "npx tsx 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",
|
||||
@@ -433,10 +405,10 @@
|
||||
"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": {
|
||||
@@ -479,27 +451,28 @@
|
||||
"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": "0.0.47",
|
||||
"@cline/core": "0.0.47",
|
||||
"@cline/llms": "0.0.47",
|
||||
"@cline/shared": "0.0.47",
|
||||
"@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",
|
||||
@@ -519,9 +492,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",
|
||||
@@ -547,13 +517,14 @@
|
||||
"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",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"ollama": "^0.5.13",
|
||||
"open": "^10.1.2",
|
||||
"open-graph-scraper": "^6.9.0",
|
||||
"openai": "^6.21.0",
|
||||
@@ -572,15 +543,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"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"overrides": {
|
||||
"tar-fs": ">=3.1.1",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,6 +55,12 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 })
|
||||
|
||||
execSync("tsc -p ./tsconfig.test.json --outDir out", { encoding: "utf-8" })
|
||||
|
||||
main().catch((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 ---")
|
||||
@@ -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)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* 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"
|
||||
|
||||
@@ -115,7 +115,8 @@ 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]
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -21,9 +21,7 @@ describe("ClineEndpoint configuration", () => {
|
||||
|
||||
// Stub os.homedir to return our temp directory
|
||||
originalHomedir = os.homedir
|
||||
sandbox
|
||||
.stub(os, "homedir")
|
||||
.returns(tempDir)
|
||||
sandbox.stub(os, "homedir").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,65 +1,18 @@
|
||||
import { ApiConfiguration, ModelInfo, QwenApiRegions } 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 {
|
||||
@@ -68,440 +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,
|
||||
})
|
||||
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 clineModelId =
|
||||
(mode === "plan" ? options.planModeClineModelId : options.actModeClineModelId) ||
|
||||
(mode === "plan" ? options.planModeOpenRouterModelId : options.actModeOpenRouterModelId)
|
||||
const clineModelInfo =
|
||||
(mode === "plan" ? options.planModeClineModelInfo : options.actModeClineModelInfo) ||
|
||||
(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 "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,166 +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 { 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" })
|
||||
})
|
||||
})
|
||||
@@ -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,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,343 +0,0 @@
|
||||
import { 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)))
|
||||
|
||||
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.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,143 +0,0 @@
|
||||
import { DeepSeekModelId, deepSeekDefaultModelId, deepSeekModels, 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 { 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
|
||||
}
|
||||
|
||||
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 }),
|
||||
...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],
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user