Compare commits

..
Author SHA1 Message Date
abeatrix d82c6abb00 patch 2026-06-02 12:30:44 -07:00
abeatrix ecdcdfa363 patches 2026-06-01 19:29:22 -07:00
abeatrix 0bf728eb53 fix(core): Prune deleted session artifacts from persisted session history
## Summary

- Filter sessions whose artifact files were manually removed from `~/.cline/data/sessions` so `listSessions` no longer returns deleted sessions
- Add background cleanup for stale persisted session rows and wire it into hub daemon-owned session services
- Cascade cleanup to child/subagent rows and align file-backed delete behavior with SQLite-backed behavior

## Validation

- `bunx vitest run sdk/packages/core/src/session/services/persistence-service.test.ts`
- `bun biome check --diagnostic-level=error ...`
- `bun -F @cline/core typecheck`

## Notes

- Missing-artifact cleanup is scheduled in the background and errors are caught/logged.
- `listSessions` filters stale rows immediately without waiting for persisted row cleanup.
2026-06-01 19:12:06 -07:00
1396 changed files with 17137 additions and 55227 deletions
+13 -9
View File
@@ -140,10 +140,12 @@ Adding a new key to global state requires updates in multiple places. Missing an
Required steps:
1. Type definition in `src/shared/storage/state-keys.ts` - Add to `GlobalState` or `Settings` interface
2. Add any default value or transform in `src/shared/storage/state-keys.ts` if the key needs one
3. Read and write the value through `StateManager` (`setGlobalState()` / `getGlobalStateKey()`) after initialization
2. Read from globalState in `src/core/storage/utils/state-helpers.ts`:
- Add `const myKey = context.globalState.get<GlobalStateAndSettings["myKey"]>("myKey")` in `readGlobalStateFromDisk()`
- Add to the return object: `myKey: myKey ?? defaultValue,`
3. StateManager handles read/write via `setGlobalState()`/`getGlobalStateKey()` after initialization
Persistent state is file-backed through `StateManager`; do not add new runtime reads or writes against VS Code `ExtensionContext` storage. That storage is only a legacy migration source.
Common mistake: Adding only the return value without the `context.globalState.get()` call. This compiles but the value is always `undefined` on load.
Settings plumbing gotcha: if a key is user-toggleable from settings, wire both controller update paths:
- `src/core/controller/state/updateSettings.ts` for webview `updateSetting(...)`
@@ -157,20 +159,22 @@ Webview toggle gotcha: settings changes must also round-trip back in state paylo
If this round-trip wiring is missing, the backend value can update but the toggle in webview appears stuck or reverts.
## StateManager Cache vs Direct globalState Access
StateManager uses an in-memory cache populated during `StateManager.initialize()` from file-backed storage. For most state, use `controller.stateManager.setGlobalState()`/`getGlobalStateKey()`.
StateManager uses an in-memory cache populated during `StateManager.initialize(context)` in `common.ts`. For most state, use `controller.stateManager.setGlobalState()`/`getGlobalStateKey()`.
Exception: host migration code may read legacy VS Code storage before file-backed storage is initialized.
Exception: State needed immediately at extension startup (before cache is ready)
Example pattern:
When Window A sets state and immediately opens Window B, the new window's StateManager cache is populated from `context.globalState` during initialization. If you need to read state in Window B right at startup (e.g., in `common.ts` during `initialize()`), read directly from `context.globalState.get()` instead of StateManager's cache.
Example pattern (see `lastShownAnnouncementId` and `worktreeAutoOpenPath`):
```typescript
// Writing (normal pattern)
controller.stateManager.setGlobalState("myKey", value)
// Reading after initialization
const value = controller.stateManager.getGlobalStateKey("myKey")
// Reading at startup in common.ts (bypass cache)
const value = context.globalState.get<string>("myKey")
```
Use `context.globalState` only in VS Code migration code that copies legacy ExtensionContext values into the shared file-backed stores.
This is only needed for cross-window state read during the brief startup window before StateManager cache is fully usable. Normal state access after initialization should use StateManager.
## ChatRow Cancelled/Interrupted States
When a ChatRow displays a loading/in-progress state (spinner), you must handle what happens when the task is cancelled. This is non-obvious because cancellation doesn't update the message content—you have to infer it from context.
+3 -15
View File
@@ -7,10 +7,10 @@ body:
value: |
**Important:** All bug reports must be reproducible using Claude Sonnet 4.5. Cline uses complex prompts so less capable models may not work as expected.
- type: dropdown
id: cline-surface
id: plugin-type
attributes:
label: Cline Surface
description: Which Cline surface are you reporting a bug for?
label: Plugin Type
description: Which plugin are you reporting a bug for?
options:
- VSCode Extension
- JetBrains Plugin
@@ -59,18 +59,6 @@ body:
placeholder: 'e.g., cline:anthropic/claude-sonnet-4.5, gemini:gemini-2.5-pro-exp-03-25'
validations:
required: false
- type: textarea
id: ide-diagnostics
attributes:
label: IDE / CLI Diagnostics
description: |
Paste the "About" diagnostics for your Cline surface. This captures the IDE build, runtime, and host details we need.
- VSCode Extension: open `Help → About` (Windows/Linux) or `Code → About Visual Studio Code` (macOS), then copy the info.
- JetBrains Plugin: open `Help → About` (Windows/Linux) or `<IDE name> → About` (macOS), then click `Copy` to grab build, runtime, OS, memory, and cores.
- CLI: there is no About dialog. Run `cline --version` and paste the output.
placeholder: Paste the copied About info or `cline --version` output here.
validations:
required: false
- type: textarea
id: system-info
attributes:
+1 -1
View File
@@ -44,7 +44,7 @@ For Responses API providers: add to `isNextGenModelProvider()` in `src/utils/mod
Modular: `components/` (shared) + `variants/` (model-specific) + `templates/` (`{{PLACEHOLDER}}`). Variants override components via `componentOverrides` in `config.ts` or custom `template.ts`. XS variant is heavily condensed inline. Always regenerate snapshots after changes.
## Global State Keys (silent failure risk)
Adding a key requires updating the typed storage definitions in `src/shared/storage/state-keys.ts`; runtime reads and writes should go through `StateManager`, not VS Code `ExtensionContext` storage. Persistent state is file-backed so it works across VS Code, CLI, and JetBrains hosts.
Adding a key requires: type in `src/shared/storage/state-keys.ts`, read via `context.globalState.get()` in `src/core/storage/utils/state-helpers.ts` `readGlobalStateFromDisk()`, and add to return object. Missing the `.get()` call compiles fine but value is always `undefined`.
## Slash Commands (3 places)
- `src/core/slash-commands/index.ts` — definitions.
+14 -14
View File
@@ -102,15 +102,15 @@ jobs:
fi
VERSION="${TAG#cli-v}"
PACKAGE_VERSION=$(node -p "require('./apps/cli/package.json').version")
PACKAGE_VERSION=$(node -p "require('./sdk/apps/cli/package.json').version")
if [ "$PACKAGE_VERSION" != "$VERSION" ]; then
echo "apps/cli/package.json version ${PACKAGE_VERSION} does not match ${TAG}"
echo "sdk/apps/cli/package.json version ${PACKAGE_VERSION} does not match ${TAG}"
exit 1
fi
if ! printf "%s\n" "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
echo "apps/cli/package.json has invalid version: ${VERSION}"
echo "sdk/apps/cli/package.json has invalid version: ${VERSION}"
exit 1
fi
@@ -147,7 +147,7 @@ jobs:
- name: Build platform binaries
run: bun script/build.ts --install-native-variants --skip-sdk-build
working-directory: apps/cli
working-directory: sdk/apps/cli
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
@@ -172,7 +172,7 @@ jobs:
)
for package_name in "${EXPECTED[@]}"; do
dir="apps/cli/dist/${package_name#@cline/}"
dir="sdk/apps/cli/dist/${package_name#@cline/}"
if [ ! -f "$dir/package.json" ]; then
echo "Missing package manifest: $dir/package.json"
exit 1
@@ -194,7 +194,7 @@ jobs:
env:
NPM_CONFIG_PROVENANCE: "true"
run: bun script/publish-npm.ts --tag latest
working-directory: apps/cli
working-directory: sdk/apps/cli
- name: Get Previous CLI Tag
id: prev_tag
@@ -207,8 +207,8 @@ jobs:
- name: Get Changelog Entry
id: changelog
run: |
# Grab content between the first "## " header and the next one in apps/cli/CHANGELOG.md
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' apps/cli/CHANGELOG.md)
# Grab content between the first "## " header and the next one in sdk/apps/cli/CHANGELOG.md
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' sdk/apps/cli/CHANGELOG.md)
echo "content<<EOF" >> $GITHUB_OUTPUT
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
@@ -349,7 +349,7 @@ jobs:
if: steps.check_commits.outputs.skip != 'true'
id: version
run: |
BASE_VERSION=$(node -p "require('./apps/cli/package.json').version")
BASE_VERSION=$(node -p "require('./sdk/apps/cli/package.json').version")
TIMESTAMP=$(date +%s)
VERSION="${BASE_VERSION}-nightly.${TIMESTAMP}"
@@ -365,17 +365,17 @@ jobs:
run: |
node -e '
const fs = require("node:fs");
const path = "apps/cli/package.json";
const path = "sdk/apps/cli/package.json";
const pkg = JSON.parse(fs.readFileSync(path, "utf8"));
pkg.version = process.env.VERSION;
fs.writeFileSync(path, `${JSON.stringify(pkg, null, "\t")}\n`);
'
cat apps/cli/package.json | grep '"version"'
cat sdk/apps/cli/package.json | grep '"version"'
- name: Build platform binaries
if: steps.check_commits.outputs.skip != 'true'
run: bun script/build.ts --install-native-variants --skip-sdk-build
working-directory: apps/cli
working-directory: sdk/apps/cli
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
@@ -401,7 +401,7 @@ jobs:
)
for package_name in "${EXPECTED[@]}"; do
dir="apps/cli/dist/${package_name#@cline/}"
dir="sdk/apps/cli/dist/${package_name#@cline/}"
if [ ! -f "$dir/package.json" ]; then
echo "Missing package manifest: $dir/package.json"
exit 1
@@ -424,7 +424,7 @@ jobs:
env:
NPM_CONFIG_PROVENANCE: "true"
run: bun script/publish-npm.ts --tag nightly
working-directory: apps/cli
working-directory: sdk/apps/cli
- name: Summary
if: steps.check_commits.outputs.skip != 'true'
@@ -31,9 +31,6 @@ 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
defaults:
run:
working-directory: apps/vscode
steps:
- name: Checkout selected branch
@@ -57,13 +54,11 @@ jobs:
# Newer LTS (Node 24 / npm 11) can make `npm list` fail with ELSPROBLEMS during vsce packaging.
node-version: 22
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode ci --include=optional
- name: Install root dependencies
run: npm ci --include=optional
- name: Install webview-ui dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui ci --include=optional
run: cd webview-ui && npm ci --include=optional
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
@@ -114,13 +114,11 @@ jobs:
with:
node-version: 22
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode install --include=optional
- name: Install root dependencies
run: npm install --include=optional
- name: Install webview-ui dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui install --include=optional
run: cd webview-ui && npm install --include=optional
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
+3 -5
View File
@@ -128,15 +128,13 @@ jobs:
restore-keys: |
playwright-browsers-${{ runner.os }}-
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode ci
- name: Install root dependencies
run: npm ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install webview-ui dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui ci
run: cd webview-ui && npm ci
- name: Install vsce
run: npm install -g @vscode/vsce
+10 -19
View File
@@ -8,7 +8,6 @@ on:
pull_request:
branches:
- main
- legacy-extension
workflow_call:
# Set default permissions for all jobs
@@ -92,15 +91,13 @@ jobs:
apps/vscode/package-lock.json
apps/vscode/webview-ui/package-lock.json
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode ci
- name: Install root dependencies
run: npm ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install webview-ui dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui ci
run: cd webview-ui && npm ci
- name: Run Quality Checks (Parallel)
run: npm run ci:check-all
@@ -133,19 +130,16 @@ jobs:
apps/vscode/package-lock.json
apps/vscode/webview-ui/package-lock.json
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode ci
- name: Install root dependencies
run: npm ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install webview-ui dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui ci
run: cd webview-ui && npm ci
- name: Set up NPM on Windows
if: runner.os == 'Windows'
working-directory: ${{ github.workspace }}
run: |
npm config set script-shell "C:\\Program Files\\Git\\bin\\bash.exe"
@@ -235,15 +229,13 @@ jobs:
apps/vscode/webview-ui/package-lock.json
apps/vscode/testing-platform/package-lock.json
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode ci
- name: Install root dependencies
run: npm ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install webview-ui dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui ci
run: cd webview-ui && npm ci
- name: Download ripgrep binaries
run: npm run download-ripgrep
@@ -252,8 +244,7 @@ jobs:
run: npm run compile-standalone
- name: Install testing platform dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/testing-platform ci
run: cd testing-platform && npm ci
- name: Running testing platform integration spec tests
timeout-minutes: 7
+3 -3
View File
@@ -17,7 +17,7 @@ jobs:
const labels = context.payload.issue.labels.map(l => l.name);
// Check if JetBrains Plugin is selected
if (body.match(/###\s*Cline Surface\s*\n+JetBrains Plugin/i)) {
if (body.match(/###\s*Plugin Type\s*\n+JetBrains Plugin/i)) {
if (!labels.includes('JetBrains')) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
@@ -29,7 +29,7 @@ jobs:
}
// Check if VSCode Extension is selected
if (body.match(/###\s*Cline Surface\s*\n+VSCode Extension/i)) {
if (body.match(/###\s*Plugin Type\s*\n+VSCode Extension/i)) {
if (!labels.includes('VS Code')) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
@@ -41,7 +41,7 @@ jobs:
}
// Check if CLI is selected
if (body.match(/###\s*Cline Surface\s*\n+CLI/i)) {
if (body.match(/###\s*Plugin Type\s*\n+CLI/i)) {
if (!labels.includes('CLI')) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
-11
View File
@@ -61,17 +61,6 @@ tests/**/cache
# Should never be committed: only exists if a publish aborts mid-swap.
.README.github.bak
# Tauri generated code
apps/*/src-tauri/gen
apps/*/src-tauri/bin
apps/examples/*/src-tauri/gen
apps/examples/*/src-tauri/bin
# Tauri UI test snapshots
apps/*/src/tests/.tui-test
apps/*/src/tests/tui-traces
apps/vscode/webview-ui/src/**/*.js
apps/vscode/webview-ui/src/**/*.js.map
# SDK Session files / User data
.cline/data
-144
View File
@@ -1,149 +1,5 @@
# Changelog
## [4.0.9]
### Added
- Add GPT-5.6 ChatGPT subscription models.
### Changed
- Soften and shorten the message shown when Cline hits the consecutive mistake limit.
### Fixed
- Handle cumulative usage snapshots from OpenAI-compatible providers so token counts are no longer over-reported.
- Load skills from files saved as UTF-8 with a byte-order mark (BOM).
## [4.0.8]
### Added
- Add more models to the GCP Vertex provider, plus a free-form entry option in the model dropdown for specifying custom Vertex models.
## [4.0.7]
### Added
- Add a ClinePass limit-reached error with a one-click option to switch to Cline usage-based billing.
- Allow selecting Cline free models on the ClinePass provider, organized into Subscribed and Free tabs with model descriptions.
### Changed
- Refine ClinePass onboarding and provider settings copy, and open the "learn more" link via the in-app URL handler.
- Remove the Cline model picker recommendation copy.
### Removed
- Remove all references to GLM 5.1.
## [4.0.6]
### Fixed
- Generalize the model capability warning so it applies more broadly.
## [4.0.5]
### Added
- Add support for Claude Sonnet 5 across the Anthropic, Bedrock, Vertex, Claude Code, SAP AI Core, OpenRouter, and Vercel AI Gateway providers, including model picker and recommended-model updates.
## [4.0.4]
### Changed
- Fully remove the ClinePass feature flag so ClinePass is available everywhere in the UI — onboarding, settings, the welcome promo banner, and the credit-limit "Switch to ClinePass" action.
## [4.0.3]
### Changed
- Enable the ClinePass provider for all users by removing the feature-flag gate that previously fell back to the standard Cline provider.
## [4.0.2]
### Added
- Add reasoning effort support (including `xhigh`) for DeepSeek thinking models.
- Improve the ClinePass provider experience with clearer reasoning controls and model selection.
### Fixed
- Show reasoning effort controls for ClinePass models and align ClinePass model resolution with the rest of the provider.
- Prefer canonical Cline Z.ai model ids and polish ClinePass and Z.ai model metadata.
- Fix environment variable replacement in the webview.
- Default focus chain settings in webview state so the toggle reflects the correct value on load.
## [4.0.1]
### Changed
- Roll the stable VS Code extension back to the pre-SDK-migration codebase to resolve regressions reported in 4.0.0. This release ships the 3.89.2 extension code under a higher version number so existing 4.0.0 users receive the update. SDK-migration work continues separately on `main`.
## [3.89.2]
### Fixed
- Complete the fix for the Anthropic provider on VS Code 1.123 and later by upgrading the bundled Anthropic SDK to a release compatible with the Node 24 runtime.
- Update the Vertex AI provider to a compatible Anthropic Vertex SDK release so it works with the upgraded Anthropic SDK.
## [3.89.1]
### Fixed
- Restore the Anthropic provider on VS Code 1.123 and later, where the updated Node 24 runtime broke the bundled Anthropic SDK.
- Handle the DeepSeek V4 reasoning format.
## [3.89.0]
### Added
- Add Claude Fable 5 model support.
### Fixed
- Fix MiniMax M3 thinking controls across gateways.
### Changed
- Clean up the Codex model list.
## [3.88.1]
### Added
- Add a debug section in settings for Cline testers.
### Fixed
- Include the walkthrough markdown files in the VS Code extension package so the first-run walkthrough steps load correctly.
## [3.88.0]
### Added
- Add the latest Fireworks AI serverless models and update the default Fireworks model to Kimi K2.6.
### Fixed
- Fix MCP server delete/add flows so settings writes do not cause the MCP server list to be emptied by the file watcher.
- Remove stale Fireworks AI models and correct Fireworks model metadata and cache pricing.
### Changed
- Always use the upstream Cline recommended models endpoint instead of gating it behind a feature flag.
## [3.87.0]
### Added
- Add MiniMax M3 model support.
### Fixed
- Update VS Code extension dependencies to resolve security issues in `@xmldom/xmldom`, `basic-ftp`, `axios`, `undici`, and other direct/transitive packages.
## [3.86.2]
### Fixed
+3 -7
View File
@@ -51,7 +51,7 @@ for CI/CD and scripting.
npm i -g cline
```
<a href="./apps/cli/README.md">Learn more</a>
<a href="./sdk/apps/cli/README.md">Learn more</a>
<br><br>
</td>
@@ -129,7 +129,7 @@ npm install @cline/sdk
| Product | Description | Location | CHANGELOG |
|---------|------------|--------------|--------------|
| **SDK** | Node.js programmatic agent API and extension exports. | [`sdk/`](https://github.com/cline/cline/tree/main/sdk) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/sdk/CHANGELOG.md) |
| **CLI** | Terminal UI, headless mode, shell commands, and CLI-specific flows. | [`apps/cli/`](https://github.com/cline/cline/tree/main/apps/cli) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/apps/cli/CHANGELOG.md) |
| **CLI** | Terminal UI, headless mode, shell commands, and CLI-specific flows. | [`sdk/apps/cli/`](https://github.com/cline/cline/tree/main/sdk/apps/cli) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/sdk/apps/cli/CHANGELOG.md) |
| **VS Code Extension** | The Marketplace extension and extension host integration. | [`/`](https://github.com/cline/cline/tree/main) (WIP migrating) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/CHANGELOG.md) |
| **JetBrains Plugin** | JetBrains-hosted client that talks to the shared agent core. | Currently we are not open-sourcing JetBrains plugins | - |
| **Kanban** | Web-based multi-agent task board. | [`cline/kanban`](https://github.com/cline/kanban) | [CHANGELOG.md](https://github.com/cline/kanban/blob/main/CHANGELOG.md) |
@@ -212,12 +212,8 @@ cline schedule create "PR summary" \
Chat with your agent from any messaging platform: Telegram, Slack, Discord, Google Chat, WhatsApp, and Linear. Each conversation thread maps to an agent session with full context. Set up access control to restrict who can interact with your agent.
```bash
# Connect to Telegram
cline connect telegram -k $BOT_TOKEN
# Connect to Slack through webhook
cline connect slack --bot-token $SLACK_TOKEN --signing-secret $SECRET --base-url $URL
# Connect to Slack using socket mode
cline connect slack --bot-token $SLACK_TOKEN --app-token $SLACK_APP_TOKEN
cline connect slack --token $SLACK_TOKEN --signing-secret $SECRET --base-url $URL
```
## Headless CLI for CI/CD
-14
View File
@@ -1,14 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
"root": false,
"extends": [
"../sdk/biome.json"
],
"linter": {
"rules": {
"a11y": {
"noStaticElementInteractions": "warn"
}
}
}
}
-145
View File
@@ -1,145 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { buildMcpInstallDefaults, runMcpInstallCommand } from "./mcp";
describe("mcp install command", () => {
it("builds stdio wizard defaults from command args", () => {
expect(
buildMcpInstallDefaults({
name: "fs",
targetArgs: [
"npx",
"-y",
"@modelcontextprotocol/server-filesystem",
"/tmp/my dir",
],
}),
).toEqual({
name: "fs",
type: "stdio",
command: 'npx -y @modelcontextprotocol/server-filesystem "/tmp/my dir"',
});
});
it("builds remote wizard defaults and normalizes http transport", () => {
expect(
buildMcpInstallDefaults({
name: "ctx7",
transport: "http",
targetArgs: ["https://mcp.context7.com/mcp"],
}),
).toEqual({
name: "ctx7",
type: "streamableHttp",
url: "https://mcp.context7.com/mcp",
});
});
it("normalizes streamable-http transport", () => {
expect(
buildMcpInstallDefaults({
name: "docs",
transport: "streamable-http",
targetArgs: ["https://example.com/mcp"],
}),
).toEqual({
name: "docs",
type: "streamableHttp",
url: "https://example.com/mcp",
});
});
it("builds SSE wizard defaults", () => {
expect(
buildMcpInstallDefaults({
name: "events",
transport: "sse",
targetArgs: ["https://example.com/sse"],
}),
).toEqual({
name: "events",
type: "sse",
url: "https://example.com/sse",
});
});
it("rejects missing stdio command and invalid remote URL", () => {
expect(() =>
buildMcpInstallDefaults({
name: "fs",
}),
).toThrow(/requires a command/);
expect(() =>
buildMcpInstallDefaults({
name: "bad",
transport: "http",
targetArgs: ["not-a-url"],
}),
).toThrow(/Invalid MCP server URL/);
});
it("rejects remote URL schemes other than http and https", () => {
expect(() =>
buildMcpInstallDefaults({
name: "bad",
transport: "http",
targetArgs: ["file:///etc/passwd"],
}),
).toThrow(/only http and https are supported/);
});
it("opens the add wizard with prefilled defaults", async () => {
const runWizard = vi.fn(async () => 0);
const code = await runMcpInstallCommand({
name: "ctx7",
transport: "http",
targetArgs: ["https://mcp.context7.com/mcp"],
isTty: true,
runWizard,
io: { writeErr: vi.fn() },
});
expect(code).toBe(0);
expect(runWizard).toHaveBeenCalledWith({
name: "ctx7",
type: "streamableHttp",
url: "https://mcp.context7.com/mcp",
});
});
it("requires a TTY because it opens the wizard", async () => {
const writeErr = vi.fn();
const runWizard = vi.fn(async () => 0);
const code = await runMcpInstallCommand({
name: "ctx7",
transport: "http",
targetArgs: ["https://mcp.context7.com/mcp"],
isTty: false,
runWizard,
io: { writeErr },
});
expect(code).toBe(1);
expect(runWizard).not.toHaveBeenCalled();
expect(writeErr).toHaveBeenCalledWith(
"cline mcp install opens the MCP wizard and requires a TTY.",
);
});
it("checks for TTY before validating install arguments", async () => {
const writeErr = vi.fn();
const code = await runMcpInstallCommand({
name: "fs",
isTty: false,
io: { writeErr },
});
expect(code).toBe(1);
expect(writeErr).toHaveBeenCalledWith(
"cline mcp install opens the MCP wizard and requires a TTY.",
);
});
});
-121
View File
@@ -1,121 +0,0 @@
import type { McpAddDefaults } from "../wizards/mcp";
export interface McpCommandIo {
writeErr: (text: string) => void;
}
export interface McpInstallOptions {
name: string;
targetArgs?: string[];
transport?: string;
io?: McpCommandIo;
isTty?: boolean;
runWizard?: (defaults: McpAddDefaults) => Promise<number>;
}
function normalizeTransportType(
value: string | undefined,
): McpAddDefaults["type"] {
const normalized = (value ?? "stdio").trim();
if (normalized === "http" || normalized === "streamable-http") {
return "streamableHttp";
}
if (
normalized === "stdio" ||
normalized === "sse" ||
normalized === "streamableHttp"
) {
return normalized;
}
throw new Error(
`Unsupported MCP transport "${normalized}". Expected stdio, sse, http, streamable-http, or streamableHttp.`,
);
}
function assertValidUrl(url: string): void {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error(`Invalid MCP server URL: ${url}`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(
`Invalid MCP server URL: ${url} (only http and https are supported)`,
);
}
}
function quoteCommandArg(arg: string): string {
if (/^[^\s"'\\]+$/.test(arg)) {
return arg;
}
return `"${arg.replace(/(["\\])/g, "\\$1")}"`;
}
export function buildMcpInstallDefaults(options: {
name: string;
targetArgs?: string[];
transport?: string;
}): McpAddDefaults {
const name = options.name.trim();
if (!name) {
throw new Error("MCP server name is required");
}
const type = normalizeTransportType(options.transport);
const targetArgs = options.targetArgs ?? [];
if (type === "stdio") {
if (targetArgs.length === 0) {
throw new Error(
"Stdio MCP install requires a command after the server name, for example: cline mcp install fs -- npx -y @modelcontextprotocol/server-filesystem /tmp",
);
}
return {
name,
type,
command: targetArgs.map(quoteCommandArg).join(" "),
};
}
if (targetArgs.length !== 1) {
throw new Error(
"Remote MCP install requires exactly one URL argument after the server name.",
);
}
const url = targetArgs[0]?.trim() ?? "";
assertValidUrl(url);
return {
name,
type,
url,
};
}
async function runPrefilledWizard(defaults: McpAddDefaults): Promise<number> {
const { runMcpWizard } = await import("../wizards/mcp");
return runMcpWizard({
initialAction: "add",
addDefaults: defaults,
exitAfterInitialAction: true,
});
}
export async function runMcpInstallCommand(
options: McpInstallOptions,
): Promise<number> {
try {
const isTty =
options.isTty ?? (process.stdin.isTTY && process.stdout.isTTY);
if (!isTty) {
throw new Error(
"cline mcp install opens the MCP wizard and requires a TTY.",
);
}
const defaults = buildMcpInstallDefaults(options);
return await (options.runWizard ?? runPrefilledWizard)(defaults);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.io?.writeErr(message);
return 1;
}
}
File diff suppressed because it is too large Load Diff
-88
View File
@@ -1,88 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildSkillsArgs } from "./skill";
describe("buildSkillsArgs", () => {
it("runs the skills package through npx with -y", () => {
expect(buildSkillsArgs(["list"])).toEqual(["-y", "skills@latest", "list"]);
});
it("injects --agent cline for install-style subcommands", () => {
expect(buildSkillsArgs(["install", "owner/repo"])).toEqual([
"-y",
"skills@latest",
"add",
"owner/repo",
"--agent",
"cline",
]);
expect(buildSkillsArgs(["add", "owner/repo"])).toContain("cline");
expect(buildSkillsArgs(["i", "owner/repo"])).toContain("cline");
expect(buildSkillsArgs(["update", "owner/repo"])).toContain("cline");
});
it("aliases uninstall to the skills remove subcommand", () => {
expect(buildSkillsArgs(["uninstall", "my-skill"])).toEqual([
"-y",
"skills@latest",
"remove",
"my-skill",
"--agent",
"cline",
]);
});
it("does not inject when the user already targeted an agent", () => {
expect(
buildSkillsArgs(["install", "owner/repo", "--agent", "cursor"]),
).not.toContain("cline");
expect(
buildSkillsArgs(["install", "owner/repo", "-a", "cursor"]),
).not.toContain("cline");
expect(
buildSkillsArgs(["install", "owner/repo", "--agent=cursor"]),
).not.toContain("cline");
});
it("aliases install and uninstall when agent options come before the subcommand", () => {
expect(
buildSkillsArgs(["--agent", "cursor", "install", "owner/repo"]),
).toEqual([
"-y",
"skills@latest",
"--agent",
"cursor",
"add",
"owner/repo",
]);
expect(
buildSkillsArgs(["--agent=cursor", "uninstall", "my-skill"]),
).toEqual(["-y", "skills@latest", "--agent=cursor", "remove", "my-skill"]);
});
it("does not scope non-install subcommands to cline", () => {
expect(buildSkillsArgs(["use", "owner/repo"])).not.toContain("--agent");
expect(buildSkillsArgs(["list"])).not.toContain("--agent");
});
it("scopes remove-style subcommands to cline", () => {
expect(buildSkillsArgs(["remove"])).toEqual([
"-y",
"skills@latest",
"remove",
"--agent",
"cline",
]);
expect(buildSkillsArgs(["rm", "my-skill"])).toContain("cline");
expect(buildSkillsArgs(["r", "my-skill"])).toContain("cline");
});
it("ignores leading flags when detecting the subcommand", () => {
expect(buildSkillsArgs(["--global", "install", "owner/repo"])).toContain(
"cline",
);
});
it("forwards an empty arg list unchanged", () => {
expect(buildSkillsArgs([])).toEqual(["-y", "skills@latest"]);
});
});
-160
View File
@@ -1,160 +0,0 @@
import { type SpawnOptions, spawn } from "node:child_process";
export interface SkillCommandIo {
writeln: (text?: string) => void;
writeErr: (text: string) => void;
}
// `cline skill` is a thin wrapper around the open skills CLI
// (https://www.npmjs.com/package/skills). We run it through `npx` so users
// don't need a separate global install. Pin the version here if we ever need to
// lock behavior to a known-good release.
const SKILLS_PACKAGE = "skills@latest";
// Subcommands that write skill files into an agent's skills directory. For a
// `cline skill` command we default these to Cline unless the user picked their
// own agent. `use` is intentionally excluded: without --agent it prints the
// generated prompt to stdout, whereas adding --agent would launch that agent
// interactively instead — not what someone scoping to Cline would expect.
const CLINE_SCOPED_SUBCOMMANDS = new Set([
"add",
"install",
"i",
"update",
"remove",
"rm",
"r",
"uninstall",
]);
const SKILLS_SUBCOMMAND_ALIASES = new Map([
["install", "add"],
["uninstall", "remove"],
]);
function hasAgentFlag(args: readonly string[]): boolean {
return args.some(
(arg) => arg === "-a" || arg === "--agent" || arg.startsWith("--agent="),
);
}
function optionConsumesNextValue(arg: string): boolean {
return arg === "-a" || arg === "--agent";
}
function findSubcommandIndex(args: readonly string[]): number {
for (let index = 0; index < args.length; index++) {
const arg = args[index];
if (arg.startsWith("-")) {
if (optionConsumesNextValue(arg)) {
index++;
}
continue;
}
return index;
}
return -1;
}
function findSubcommand(args: readonly string[]): string | undefined {
const index = findSubcommandIndex(args);
return index >= 0 ? args[index] : undefined;
}
function normalizeSkillsSubcommandAliases(args: string[]): void {
const index = findSubcommandIndex(args);
if (index < 0) return;
const alias = SKILLS_SUBCOMMAND_ALIASES.get(args[index]);
if (alias) {
args[index] = alias;
}
}
/**
* Build the argument list passed to `npx`, injecting `--agent cline` for
* install-style subcommands unless the user already targeted an agent.
*/
export function buildSkillsArgs(userArgs: readonly string[]): string[] {
const args = [...userArgs];
const subcommand = findSubcommand(args);
normalizeSkillsSubcommandAliases(args);
if (
subcommand &&
CLINE_SCOPED_SUBCOMMANDS.has(subcommand) &&
!hasAgentFlag(args)
) {
args.push("--agent", "cline");
}
return ["-y", SKILLS_PACKAGE, ...args];
}
function resolveExitCode(
code: number | null,
signal: NodeJS.Signals | null,
): number {
if (code !== null) {
return code;
}
switch (signal) {
case "SIGINT":
return 130;
case "SIGTERM":
return 143;
default:
return 1;
}
}
/**
* Forward all arguments to the open skills CLI via `npx skills`.
*
* Returns the child process exit code, or 1 if npx is unavailable or fails to
* spawn. stdio is inherited so the skills CLI's interactive prompts and output
* pass straight through to the user's terminal.
*/
export async function runSkillCommand(
userArgs: readonly string[],
io: SkillCommandIo,
): Promise<number> {
const args = buildSkillsArgs(userArgs);
const isWindows = process.platform === "win32";
const options: SpawnOptions = {
stdio: "inherit",
env: process.env,
// Prevent a console window from flashing on Windows.
windowsHide: true,
...(isWindows ? { shell: true } : {}),
};
return new Promise<number>((resolve) => {
const child = spawn("npx", args, options);
const forward = (signal: NodeJS.Signals) => {
child.kill(signal);
};
const handleSigint = () => forward("SIGINT");
const handleSigterm = () => forward("SIGTERM");
process.on("SIGINT", handleSigint);
process.on("SIGTERM", handleSigterm);
const cleanup = () => {
process.off("SIGINT", handleSigint);
process.off("SIGTERM", handleSigterm);
};
child.once("error", (error: NodeJS.ErrnoException) => {
cleanup();
if (error.code === "ENOENT") {
io.writeErr(
'npx was not found. Install Node.js (which includes npx) to use "cline skill".',
);
} else {
io.writeErr(`Failed to run npx ${SKILLS_PACKAGE}: ${error.message}`);
}
resolve(1);
});
child.once("close", (code, signal) => {
cleanup();
resolve(resolveExitCode(code, signal));
});
});
}
-255
View File
@@ -1,255 +0,0 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
autoUpdateOnStartup,
checkForUpdates,
getInstallationInfo,
PackageManager,
resolveCliHubOwnerContext,
withMinimumReleaseAgeBypass,
} from "./update";
const originalArgv = [...process.argv];
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
const originalDataDir = process.env.CLINE_DATA_DIR;
const originalHubDiscoveryPath = process.env.CLINE_HUB_DISCOVERY_PATH;
const originalWrapperPath = process.env.CLINE_WRAPPER_PATH;
const originalGlobalSettingsPath = process.env.CLINE_GLOBAL_SETTINGS_PATH;
const originalIsDev = process.env.IS_DEV;
const originalNoAutoUpdate = process.env.CLINE_NO_AUTO_UPDATE;
const tempDirs: string[] = [];
function createFile(path: string): string {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, "");
return path;
}
function createTempFile(pathSuffix: string): string {
const root = mkdtempSync(join(tmpdir(), "cline-update-test-"));
tempDirs.push(root);
return createFile(join(root, pathSuffix));
}
describe("getInstallationInfo", () => {
afterEach(() => {
process.argv = [...originalArgv];
if (originalBuildEnv === undefined) {
delete process.env.CLINE_BUILD_ENV;
} else {
process.env.CLINE_BUILD_ENV = originalBuildEnv;
}
if (originalDataDir === undefined) {
delete process.env.CLINE_DATA_DIR;
} else {
process.env.CLINE_DATA_DIR = originalDataDir;
}
if (originalHubDiscoveryPath === undefined) {
delete process.env.CLINE_HUB_DISCOVERY_PATH;
} else {
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
}
if (originalWrapperPath === undefined) {
delete process.env.CLINE_WRAPPER_PATH;
} else {
process.env.CLINE_WRAPPER_PATH = originalWrapperPath;
}
if (originalGlobalSettingsPath === undefined) {
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
} else {
process.env.CLINE_GLOBAL_SETTINGS_PATH = originalGlobalSettingsPath;
}
if (originalIsDev === undefined) {
delete process.env.IS_DEV;
} else {
process.env.IS_DEV = originalIsDev;
}
if (originalNoAutoUpdate === undefined) {
delete process.env.CLINE_NO_AUTO_UPDATE;
} else {
process.env.CLINE_NO_AUTO_UPDATE = originalNoAutoUpdate;
}
vi.restoreAllMocks();
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it("detects npm installs from the wrapper path passed to the compiled binary", () => {
const wrapperPath = createTempFile("lib/node_modules/cline/bin/cline");
process.env.CLINE_WRAPPER_PATH = wrapperPath;
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
expect(getInstallationInfo("1.2.3")).toEqual({
packageManager: PackageManager.NPM,
packageName: "cline",
updateCommand: "npm update -g cline --tag latest",
});
});
it("uses the nightly tag when the current CLI version is nightly", () => {
const wrapperPath = createTempFile("lib/node_modules/cline/bin/cline");
process.env.CLINE_WRAPPER_PATH = wrapperPath;
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
expect(getInstallationInfo("1.2.3-nightly.456")).toEqual({
packageManager: PackageManager.NPM,
packageName: "cline",
updateCommand: "npm update -g cline --tag nightly",
});
});
it("falls back to unknown when only Bun's virtual compiled path is available", () => {
delete process.env.CLINE_WRAPPER_PATH;
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
expect(getInstallationInfo("1.2.3")).toEqual({
packageManager: PackageManager.UNKNOWN,
packageName: "cline",
});
});
});
describe("auto update settings", () => {
afterEach(() => {
process.argv = [...originalArgv];
if (originalBuildEnv === undefined) {
delete process.env.CLINE_BUILD_ENV;
} else {
process.env.CLINE_BUILD_ENV = originalBuildEnv;
}
if (originalDataDir === undefined) {
delete process.env.CLINE_DATA_DIR;
} else {
process.env.CLINE_DATA_DIR = originalDataDir;
}
if (originalHubDiscoveryPath === undefined) {
delete process.env.CLINE_HUB_DISCOVERY_PATH;
} else {
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
}
if (originalWrapperPath === undefined) {
delete process.env.CLINE_WRAPPER_PATH;
} else {
process.env.CLINE_WRAPPER_PATH = originalWrapperPath;
}
if (originalGlobalSettingsPath === undefined) {
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
} else {
process.env.CLINE_GLOBAL_SETTINGS_PATH = originalGlobalSettingsPath;
}
if (originalIsDev === undefined) {
delete process.env.IS_DEV;
} else {
process.env.IS_DEV = originalIsDev;
}
if (originalNoAutoUpdate === undefined) {
delete process.env.CLINE_NO_AUTO_UPDATE;
} else {
process.env.CLINE_NO_AUTO_UPDATE = originalNoAutoUpdate;
}
vi.restoreAllMocks();
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it("skips startup auto update when disabled globally", () => {
const settingsPath = createTempFile("data/global-settings.json");
writeFileSync(settingsPath, JSON.stringify({ autoUpdateEnabled: false }));
process.env.CLINE_GLOBAL_SETTINGS_PATH = settingsPath;
delete process.env.IS_DEV;
delete process.env.CLINE_NO_AUTO_UPDATE;
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockRejectedValue(new Error("should not fetch"));
autoUpdateOnStartup();
expect(fetchSpy).not.toHaveBeenCalled();
});
it("still lets manual update checks run when startup auto update is disabled", async () => {
const settingsPath = createTempFile("data/global-settings.json");
writeFileSync(settingsPath, JSON.stringify({ autoUpdateEnabled: false }));
process.env.CLINE_GLOBAL_SETTINGS_PATH = settingsPath;
delete process.env.CLINE_NO_AUTO_UPDATE;
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ version: "0.0.0" }),
} as Response);
await checkForUpdates({ includeKanban: false });
expect(fetchSpy).toHaveBeenCalled();
});
});
describe("hub restart owner selection", () => {
afterEach(() => {
if (originalBuildEnv === undefined) {
delete process.env.CLINE_BUILD_ENV;
} else {
process.env.CLINE_BUILD_ENV = originalBuildEnv;
}
if (originalDataDir === undefined) {
delete process.env.CLINE_DATA_DIR;
} else {
process.env.CLINE_DATA_DIR = originalDataDir;
}
if (originalHubDiscoveryPath === undefined) {
delete process.env.CLINE_HUB_DISCOVERY_PATH;
} else {
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
}
});
it("uses the shared hub owner outside production builds", () => {
process.env.CLINE_BUILD_ENV = "development";
process.env.CLINE_DATA_DIR = "/tmp/cline-update-test-data";
delete process.env.CLINE_HUB_DISCOVERY_PATH;
const owner = resolveCliHubOwnerContext();
expect(owner.discoveryPath).toContain("/locks/hub/owners/");
expect(owner.discoveryPath).not.toBe(
"/tmp/cline-update-test-data/locks/hub/production.json",
);
});
});
describe("withMinimumReleaseAgeBypass", () => {
it("adds the package-manager-specific cooldown bypass", () => {
expect(
withMinimumReleaseAgeBypass(
"npm update -g cline --tag latest",
PackageManager.NPM,
).command,
).toBe("npm update -g cline --tag latest --min-release-age=0");
expect(
withMinimumReleaseAgeBypass("bun add -g cline@latest", PackageManager.BUN)
.command,
).toBe("bun add -g cline@latest --minimum-release-age=0");
expect(
withMinimumReleaseAgeBypass(
"yarn global add cline@latest",
PackageManager.YARN,
).command,
).toBe("yarn global add cline@latest");
expect(
withMinimumReleaseAgeBypass(
"yarn global add cline@latest",
PackageManager.YARN,
).env?.YARN_NPM_MINIMAL_AGE_GATE,
).toBe("0");
expect(
withMinimumReleaseAgeBypass(
"pnpm add -g cline@latest",
PackageManager.PNPM,
).env?.pnpm_config_minimum_release_age,
).toBe("0");
});
});
-2
View File
@@ -1,2 +0,0 @@
export type { ConnectorCatalogEntry } from "@cline/shared";
export { CONNECTOR_CATALOG, listConnectorCatalog } from "@cline/shared";
@@ -1,163 +0,0 @@
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 () => {
const actual =
await vi.importActual<typeof import("@cline/core")>("@cline/core");
return {
...actual,
ProviderSettingsManager: class {
getLastUsedProviderSettings(options?: unknown) {
return mockGetLastUsedProviderSettings(options);
}
getProviderSettings(providerId: string) {
return mockGetProviderSettings(providerId);
}
},
CoreSessionService: class {},
SqliteSessionStore: class {},
Llms: {
...actual.Llms,
getProviderCollection: mockGetProviderCollection,
},
};
});
vi.mock("../runtime/prompt", () => ({
resolveSystemPrompt: mockResolveSystemPrompt,
}));
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")>(
"../commands/auth",
);
return {
...actual,
ensureOAuthProviderApiKey: vi.fn(),
};
});
import { buildConnectorStartRequest } from "./session-runtime";
describe("buildConnectorStartRequest", () => {
beforeEach(() => {
mockGetBooleanFlagEnabled.mockReturnValue(false);
});
afterEach(() => {
vi.clearAllMocks();
delete process.env.OPENROUTER_API_KEY;
});
it("falls back to provider env vars when persisted settings have no api key", async () => {
mockGetLastUsedProviderSettings.mockReturnValue({ provider: "openrouter" });
mockGetProviderSettings.mockReturnValue({
provider: "openrouter",
model: "anthropic/claude-sonnet-4.6",
});
mockGetProviderCollection.mockReturnValue({
provider: { env: ["OPENROUTER_API_KEY"] },
});
mockResolveSystemPrompt.mockResolvedValue("system");
process.env.OPENROUTER_API_KEY = "env-openrouter-key";
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",
});
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.2",
});
expect(request.provider).toBe("cline-pass");
expect(request.apiKey).toBe("workos:resolved-token");
expect(request.model).toBe("cline-pass/glm-5.2");
});
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.2",
});
expect(request.provider).toBe("cline-pass");
expect(request.apiKey).toBe("workos:resolved-token");
expect(request.model).toBe("cline-pass/glm-5.2");
});
});
-251
View File
@@ -1,251 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Config } from "../utils/types";
const coreMocks = vi.hoisted(() => {
const serviceOptions: Array<{
apiBaseUrl: string;
getAuthToken: () => Promise<string | undefined | null>;
}> = [];
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")>();
return {
...actual,
ClineAccountService: class {
constructor(options: {
apiBaseUrl: string;
getAuthToken: () => Promise<string | undefined | null>;
}) {
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) {
return coreMocks.getProviderSettings(providerId);
}
saveProviderSettings(settings: unknown, options?: unknown) {
coreMocks.saveProviderSettings(settings, options);
}
},
};
});
vi.mock("../utils/telemetry", () => ({
identifyTelemetryAccount: telemetryMocks.identifyTelemetryAccount,
}));
function makeConfig(overrides: Partial<Config> = {}): Config {
return {
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
apiKey: "",
verbose: false,
sandbox: false,
thinking: false,
outputMode: "text",
mode: "act",
defaultToolAutoApprove: false,
toolPolicies: {},
enableTools: true,
cwd: "/tmp/workspace",
logger: {
debug: vi.fn(),
log: vi.fn(),
error: vi.fn(),
},
...overrides,
} as unknown as Config;
}
function mockFetchJson(body: unknown, status = 200): void {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
}),
) as unknown as typeof fetch,
);
}
describe("createClineAccountService", () => {
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("refreshes persisted Cline OAuth credentials before creating the account service", async () => {
vi.spyOn(Date, "now").mockReturnValue(100_000);
mockFetchJson({
success: true,
data: {
accessToken: "new-access",
refreshToken: "new-refresh",
tokenType: "Bearer",
expiresAt: "2096-10-02T07:06:40.000Z",
userInfo: {
subject: "sub-new",
email: "new@example.com",
name: "New User",
clineUserId: "acct-new",
accounts: [],
},
},
});
coreMocks.getProviderSettings.mockReturnValue({
provider: "cline",
auth: {
accessToken: "workos:old-access",
refreshToken: "refresh-token",
accountId: "acct-old",
expiresAt: 1,
},
});
const { createClineAccountService } = await import("./cline-account");
const service = await createClineAccountService({ config: makeConfig() });
expect(service).toBeDefined();
expect(globalThis.fetch).toHaveBeenCalled();
expect(coreMocks.saveProviderSettings).toHaveBeenCalledWith(
expect.objectContaining({
provider: "cline",
auth: expect.objectContaining({
accessToken: "workos:new-access",
refreshToken: "new-refresh",
accountId: "acct-new",
expiresAt: 4_000_000_000_000,
}),
}),
{ setLastUsed: false, tokenSource: "oauth" },
);
expect(await coreMocks.serviceOptions[0]?.getAuthToken()).toBe(
"workos:new-access",
);
});
it("asks the user to re-authenticate when Cline OAuth credentials cannot refresh", async () => {
vi.spyOn(Date, "now").mockReturnValue(100_000);
mockFetchJson(
{
error: "invalid_grant",
error_description: "refresh expired",
},
401,
);
coreMocks.getProviderSettings.mockReturnValue({
provider: "cline",
auth: {
accessToken: "workos:old-access",
refreshToken: "refresh-token",
expiresAt: 1,
},
});
const { createClineAccountService } = await import("./cline-account");
await expect(
createClineAccountService({ config: makeConfig() }),
).rejects.toThrow(
"Cline account requires re-authentication. Run cline auth cline.",
);
});
});
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),
);
});
});
@@ -1,44 +0,0 @@
import { describe, expect, it } from "vitest";
import {
formatCliErrorMessage,
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassSubscriptionUrl,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassSubscriptionError,
} from "./cline-pass-errors";
describe("cline-pass-errors", () => {
it("recognizes both raw and formatted ClinePass subscription messages", () => {
expect(
isClinePassSubscriptionError(
"the user is not subscribed to required model plan",
),
).toBe(true);
const formatted = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${getClinePassSubscriptionUrl()}`;
expect(isClinePassSubscriptionError(formatted)).toBe(true);
expect(formatCliErrorMessage(new Error(formatted))).toBe(formatted);
});
it("formats the ClinePass subscription URL", () => {
expect(getClinePassSubscriptionUrl()).toBe(
"https://app.cline.bot/dashboard/subscription?personal=true",
);
});
it("recognizes and formats organization account individual subscription errors", () => {
const raw =
"403 Error 403: organization accounts cannot use individual model inference subscriptions";
const formatted = getClineOrgIndividualInferenceSubscriptionMessage();
expect(isClineOrgIndividualInferenceSubscriptionErrorMessage(raw)).toBe(
true,
);
expect(
isClineOrgIndividualInferenceSubscriptionErrorMessage(
new Error(formatted),
),
).toBe(true);
expect(formatCliErrorMessage(new Error(raw))).toBe(formatted);
});
});
-69
View File
@@ -1,69 +0,0 @@
import {
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassSubscriptionUrl,
isClineNotSubscribedError,
isClineNotSubscribedMessage,
isClineOrgIndividualInferenceSubscriptionError,
isClineOrgIndividualInferenceSubscriptionMessage,
} from "@cline/core";
export {
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassSubscriptionUrl,
};
function isFormattedClinePassSubscriptionMessage(message: string): boolean {
const normalized = message.trim().toLowerCase();
return (
normalized.includes("no access to clinepass subscription models yet") &&
normalized.includes("subscribe to clinepass")
);
}
export function isClinePassSubscriptionError(error: unknown): boolean {
if (isClineNotSubscribedError(error)) {
return true;
}
if (error instanceof Error) {
return (
error.name === "ClineNotSubscribedError" ||
isClineNotSubscribedMessage(error.message) ||
isFormattedClinePassSubscriptionMessage(error.message)
);
}
return (
typeof error === "string" &&
(isClineNotSubscribedMessage(error) ||
isFormattedClinePassSubscriptionMessage(error))
);
}
export function isClineOrgIndividualInferenceSubscriptionErrorMessage(
error: unknown,
): boolean {
if (isClineOrgIndividualInferenceSubscriptionError(error)) {
return true;
}
if (error instanceof Error) {
return (
error.name === "ClineOrgIndividualInferenceSubscriptionError" ||
isClineOrgIndividualInferenceSubscriptionMessage(error.message) ||
error.message === getClineOrgIndividualInferenceSubscriptionMessage()
);
}
return (
typeof error === "string" &&
(isClineOrgIndividualInferenceSubscriptionMessage(error) ||
error === getClineOrgIndividualInferenceSubscriptionMessage())
);
}
export function formatCliErrorMessage(error: unknown): string {
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(error)) {
return getClineOrgIndividualInferenceSubscriptionMessage();
}
if (error instanceof Error) {
return error.message;
}
return String(error);
}
-19
View File
@@ -1,19 +0,0 @@
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);
});
});
-118
View File
@@ -1,118 +0,0 @@
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 function setCliFeatureFlagsAccountContext(account: {
id?: string;
email?: string;
}): void {
const accountId = account.id?.trim();
cliFeatureFlagsContext = {
...cliFeatureFlagsContext,
...(accountId ? { distinctId: accountId, userId: accountId } : {}),
...(account.email?.trim() ? { email: account.email.trim() } : {}),
};
cliFeatureFlagsService?.setContext(getCliFeatureFlagsContext());
}
export async function identifyFeatureFlagsAccount(
account: { id?: string; email?: string },
logger?: BasicLogger,
): Promise<void> {
setCliFeatureFlagsAccountContext(account);
if (!cliFeatureFlagsService) {
return;
}
try {
await cliFeatureFlagsService.poll();
} catch (error) {
logger?.error?.("Error polling CLI feature flags", { error });
}
}
-162
View File
@@ -1,162 +0,0 @@
import type { AgentEvent } from "@cline/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
clearClineFreeModelCostCache,
shouldZeroClineFreeModelCost,
zeroCliAgentEventCost,
zeroCliUsageCost,
} from "./free-model-cost";
afterEach(() => {
clearClineFreeModelCostCache();
vi.unstubAllGlobals();
});
describe("shouldZeroClineFreeModelCost", () => {
it("uses the Cline free model list", async () => {
const fetchMock = vi.fn(
async (_input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
return new Response(
JSON.stringify({
free: [{ id: "deepseek/deepseek-v4-flash" }],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
},
);
vi.stubGlobal("fetch", fetchMock);
await expect(
shouldZeroClineFreeModelCost({
providerId: "cline",
modelId: "deepseek/deepseek-v4-flash",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(true);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
"https://cline.test/api/v1/ai/cline/recommended-models",
);
});
it("does not zero non-Cline providers", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(
shouldZeroClineFreeModelCost({
providerId: "openrouter",
modelId: "deepseek/deepseek-v4-flash",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});
it("does not match a paid model by only the final path segment", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
return new Response(
JSON.stringify({
free: [{ id: "deepseek/deepseek-v4-flash" }],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}),
);
await expect(
shouldZeroClineFreeModelCost({
providerId: "cline",
modelId: "acme/deepseek-v4-flash",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(false);
});
it("retries after a failed free model list fetch", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response("unavailable", { status: 503 }))
.mockResolvedValueOnce(
new Response(
JSON.stringify({
free: [{ id: "deepseek/deepseek-v4-flash" }],
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
vi.stubGlobal("fetch", fetchMock);
await expect(
shouldZeroClineFreeModelCost({
providerId: "cline",
modelId: "deepseek/deepseek-v4-flash",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(false);
await expect(
shouldZeroClineFreeModelCost({
providerId: "cline",
modelId: "deepseek/deepseek-v4-flash",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
describe("zeroCliUsageCost", () => {
it("zeros total cost while preserving token usage", () => {
expect(
zeroCliUsageCost(
{
inputTokens: 10,
outputTokens: 5,
totalCost: 0.001,
},
true,
),
).toEqual({
inputTokens: 10,
outputTokens: 5,
totalCost: 0,
});
});
});
describe("zeroCliAgentEventCost", () => {
it("zeros usage event cost fields", () => {
const event = {
type: "usage",
inputTokens: 10,
outputTokens: 5,
cost: 0.001,
totalCost: 0.001,
} as AgentEvent;
expect(zeroCliAgentEventCost(event, true)).toMatchObject({
cost: 0,
totalCost: 0,
});
});
it("zeros done event usage cost", () => {
const event = {
type: "done",
reason: "completed",
text: "ok",
iterations: 1,
usage: {
inputTokens: 10,
outputTokens: 5,
totalCost: 0.001,
},
} as AgentEvent;
expect(zeroCliAgentEventCost(event, true)).toMatchObject({
usage: { totalCost: 0 },
});
});
});
-123
View File
@@ -1,123 +0,0 @@
import type { AgentEvent } from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import type { Config } from "./types";
const CLINE_RECOMMENDED_MODELS_TIMEOUT_MS = 5_000;
const freeModelIdsByBaseUrl = new Map<
string,
Promise<readonly string[] | undefined>
>();
function normalizeModelId(modelId: string | undefined): string {
return modelId?.trim().toLowerCase() ?? "";
}
function modelIdsMatch(selectedModelId: string, freeModelId: string): boolean {
const selected = normalizeModelId(selectedModelId);
const free = normalizeModelId(freeModelId);
if (!selected || !free) return false;
return selected === free;
}
function resolveClineRecommendedModelsUrl(baseUrl: string): string {
const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
const apiBaseUrl = normalizedBaseUrl.endsWith("/api/v1")
? normalizedBaseUrl.slice(0, -"/api/v1".length)
: normalizedBaseUrl;
return `${apiBaseUrl}/api/v1/ai/cline/recommended-models`;
}
async function fetchClineFreeModelIds(
baseUrl: string,
): Promise<readonly string[] | undefined> {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
CLINE_RECOMMENDED_MODELS_TIMEOUT_MS,
);
try {
const response = await fetch(resolveClineRecommendedModelsUrl(baseUrl), {
signal: controller.signal,
});
if (!response.ok) return undefined;
const json = (await response.json()) as { free?: unknown };
return Array.isArray(json.free)
? json.free
.map((model) =>
model && typeof model === "object"
? (model as Record<string, unknown>).id
: undefined,
)
.filter((id): id is string => typeof id === "string" && id.length > 0)
: [];
} catch {
return undefined;
} finally {
clearTimeout(timeout);
}
}
function getClineFreeModelIds(baseUrl: string): Promise<readonly string[]> {
const cacheKey = baseUrl.trim();
let cached = freeModelIdsByBaseUrl.get(cacheKey);
if (!cached) {
cached = fetchClineFreeModelIds(cacheKey).then((ids) => {
if (!ids) freeModelIdsByBaseUrl.delete(cacheKey);
return ids;
});
freeModelIdsByBaseUrl.set(cacheKey, cached);
}
return cached.then((ids) => ids ?? []);
}
export async function shouldZeroClineFreeModelCost(
config: Pick<Config, "providerId" | "modelId" | "baseUrl">,
): Promise<boolean> {
if (config.providerId !== "cline") return false;
const modelId = normalizeModelId(config.modelId);
if (!modelId) return false;
const baseUrl =
config.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl;
const freeModelIds = await getClineFreeModelIds(baseUrl);
return freeModelIds.some((freeModelId) =>
modelIdsMatch(modelId, freeModelId),
);
}
export function zeroCliUsageCost<T extends { totalCost?: number } | undefined>(
usage: T,
shouldZeroCost: boolean,
): T {
if (
!shouldZeroCost ||
!usage ||
typeof usage.totalCost !== "number" ||
usage.totalCost === 0
) {
return usage;
}
return { ...usage, totalCost: 0 } as T;
}
export function zeroCliAgentEventCost(
event: AgentEvent,
shouldZeroCost: boolean,
): AgentEvent {
if (!shouldZeroCost) return event;
if (event.type === "done" && event.usage) {
return {
...event,
usage: zeroCliUsageCost(event.usage, true),
};
}
if (event.type !== "usage") return event;
const next = { ...event } as Record<string, unknown>;
if (typeof next.cost === "number") next.cost = 0;
if (typeof next.totalCost === "number") next.totalCost = 0;
return next as unknown as AgentEvent;
}
export function clearClineFreeModelCostCache(): void {
freeModelIdsByBaseUrl.clear();
}
-99
View File
@@ -1,99 +0,0 @@
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();
});
});
-115
View File
@@ -1,115 +0,0 @@
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)));
});
}
@@ -1,34 +0,0 @@
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,
});
});
});
-14
View File
@@ -1,14 +0,0 @@
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"),
});
}
@@ -1,81 +0,0 @@
import { describe, expect, it } from "vitest";
import { PLATFORMS, shouldIncludeField } from "./platforms";
describe("connect wizard platform security fields", () => {
it("does not ask Telegram users to re-enter the bot username", () => {
const telegram = PLATFORMS.find((platform) => platform.id === "telegram");
expect(telegram?.fields.map((field) => field.label)).toEqual(["Bot token"]);
expect(telegram?.fields.map((field) => field.flag)).toEqual(["-k"]);
});
it("rejects unsafe Telegram and Slack access restriction identifiers", () => {
const telegram = PLATFORMS.find((platform) => platform.id === "telegram");
const slack = PLATFORMS.find((platform) => platform.id === "slack");
const telegramUser = telegram?.security?.fields.find(
(field) => field.key === "userId",
);
const slackTeam = slack?.security?.fields.find(
(field) => field.key === "teamId",
);
const slackUser = slack?.security?.fields.find(
(field) => field.key === "userId",
);
expect(telegramUser?.validate?.("123456")).toBeUndefined();
expect(telegramUser?.validate?.("123; rm -rf /")).toContain("digits");
expect(slackTeam?.validate?.("T01ABC123")).toBeUndefined();
expect(slackTeam?.validate?.("T01;bad")).toContain("Slack workspace");
expect(slackUser?.validate?.("U01ABC123")).toBeUndefined();
expect(slackUser?.validate?.("U01$(bad)")).toContain("Slack member");
});
it("uses the Telegram allowed user ID flag for wizard security", () => {
const telegram = PLATFORMS.find((platform) => platform.id === "telegram");
const args = telegram?.security?.buildArgs({
userId: "123456",
});
expect(args).toEqual(["--allowed-user-id", "123456"]);
});
it("builds an exact-match Slack authorization hook", () => {
const slack = PLATFORMS.find((platform) => platform.id === "slack");
const args = slack?.security?.buildArgs({
teamId: "T01ABC123",
userId: "U01ABC123",
});
expect(args).toEqual([
"--hook-command",
`jq -r ".payload.actor.participantKey" | grep -qx "slack:team:T01ABC123:user:U01ABC123" && echo '{"action":"allow"}' || echo '{"action":"deny"}'`,
]);
});
it("asks Slack users for mode-specific setup fields", () => {
const slack = PLATFORMS.find((platform) => platform.id === "slack");
const fields = slack?.fields ?? [];
const webhookValues = { "--base-url": "https://example.test" };
const socketValues = { "--base-url": "" };
expect(fields.map((field) => field.flag)).toEqual([
"--bot-token",
"--base-url",
"--signing-secret",
"--app-token",
]);
expect(
fields
.filter((field) => shouldIncludeField(field, webhookValues))
.map((field) => field.flag),
).toEqual(["--bot-token", "--base-url", "--signing-secret"]);
expect(
fields
.filter((field) => shouldIncludeField(field, socketValues))
.map((field) => field.flag),
).toEqual(["--bot-token", "--base-url", "--app-token"]);
});
});
-16
View File
@@ -1,16 +0,0 @@
import {
CONNECTOR_PLATFORMS,
shouldIncludeConnectorField,
} from "@cline/shared";
export type {
ConnectorFieldCondition as FieldCondition,
ConnectorFieldDef as FieldDef,
ConnectorPlatformDef as PlatformDef,
ConnectorSecurityDef as SecurityDef,
ConnectorSecurityFieldDef as SecurityFieldDef,
} from "@cline/shared";
export { CONNECTOR_PLATFORMS, shouldIncludeConnectorField };
export const PLATFORMS = CONNECTOR_PLATFORMS;
export const shouldIncludeField = shouldIncludeConnectorField;
-45
View File
@@ -1,45 +0,0 @@
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,
options: { throwOnError?: boolean } = {},
): 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) {
if (options.throwOnError === true) {
throw error instanceof Error ? error : new Error(toErrorMessage(error));
}
p.log.error(`OAuth authorization failed: ${toErrorMessage(error)}`);
p.log.warn(
`Server "${name}" is still saved. Choose "Authorize OAuth" to retry.`,
);
}
}
@@ -1,359 +0,0 @@
import { describe, expect, it } from "vitest";
import {
allowedBrowserHosts,
allowedBrowserOrigins,
isAuthorizedBrowserRequest,
isAuthorizedBrowserToDesktopRequest,
requiresBrowserRequestAuth,
} from "./browser-auth";
const defaultOptions = {
bindHost: "127.0.0.1",
port: 8787,
publicUrl: "http://127.0.0.1:8787",
};
const publicRoute = (_req: Request, url: URL) => url.pathname === "/public";
function browserRequest(
origin?: string,
init?: Omit<RequestInit, "headers"> & {
headers?: Record<string, string>;
},
): Request {
return new Request("http://127.0.0.1:8787/browser", {
...init,
headers: {
host: "127.0.0.1:8787",
...(origin === undefined ? {} : { origin }),
...(init?.headers ?? {}),
},
});
}
describe("allowedBrowserOrigins", () => {
it("allows the configured public URL origin and local aliases for local binds", () => {
expect([...allowedBrowserOrigins(defaultOptions)].sort()).toEqual([
"http://127.0.0.1:8787",
"http://[::1]:8787",
"http://localhost:8787",
]);
});
it("uses the configured public URL scheme for local aliases", () => {
expect(
[
...allowedBrowserOrigins({
...defaultOptions,
publicUrl: "https://127.0.0.1:8787",
}),
].sort(),
).toEqual([
"https://127.0.0.1:8787",
"https://[::1]:8787",
"https://localhost:8787",
]);
});
it("omits default protocol ports for local alias origins", () => {
expect(
[
...allowedBrowserOrigins({
bindHost: "127.0.0.1",
port: 80,
publicUrl: "http://localhost",
}),
].sort(),
).toEqual(["http://127.0.0.1", "http://[::1]", "http://localhost"]);
expect(
[
...allowedBrowserOrigins({
bindHost: "127.0.0.1",
port: 443,
publicUrl: "https://localhost",
}),
].sort(),
).toEqual(["https://127.0.0.1", "https://[::1]", "https://localhost"]);
});
it("allows the configured public URL origin and explicit bind origin for non-local binds", () => {
expect(
[
...allowedBrowserOrigins({
bindHost: "0.0.0.0",
port: 8787,
publicUrl: "https://example.ngrok-free.app",
roomSecret: "secret",
}),
].sort(),
).toEqual(["https://0.0.0.0:8787", "https://example.ngrok-free.app"]);
});
});
describe("allowedBrowserHosts", () => {
it("allows the configured public URL host and local aliases for local binds", () => {
expect([...allowedBrowserHosts(defaultOptions)].sort()).toEqual([
"127.0.0.1:8787",
"[::1]:8787",
"localhost:8787",
]);
});
it("omits default protocol ports for local alias hosts", () => {
expect(
[
...allowedBrowserHosts({
bindHost: "127.0.0.1",
port: 80,
publicUrl: "http://localhost",
}),
].sort(),
).toEqual(["127.0.0.1", "[::1]", "localhost"]);
expect(
[
...allowedBrowserHosts({
bindHost: "127.0.0.1",
port: 443,
publicUrl: "https://localhost",
}),
].sort(),
).toEqual(["127.0.0.1", "[::1]", "localhost"]);
});
it("allows the configured public URL host and explicit bind host for non-local binds", () => {
expect(
[
...allowedBrowserHosts({
bindHost: "0.0.0.0",
port: 8787,
publicUrl: "https://example.ngrok-free.app",
roomSecret: "secret",
}),
].sort(),
).toEqual(["0.0.0.0:8787", "example.ngrok-free.app"]);
});
});
describe("requiresBrowserRequestAuth", () => {
it("does not require browser auth for public GET routes", () => {
expect(
requiresBrowserRequestAuth(
new Request("http://127.0.0.1:8787/public"),
new URL("http://127.0.0.1:8787/public"),
publicRoute,
),
).toBe(false);
});
it("requires browser auth for unknown paths even when they use GET", () => {
expect(
requiresBrowserRequestAuth(
new Request("http://127.0.0.1:8787/future-api"),
new URL("http://127.0.0.1:8787/future-api"),
publicRoute,
),
).toBe(true);
});
it("requires browser auth for privileged paths even when they use GET", () => {
expect(
requiresBrowserRequestAuth(
new Request("http://127.0.0.1:8787/browser"),
new URL("http://127.0.0.1:8787/browser"),
publicRoute,
),
).toBe(true);
});
it("requires browser auth for every WebSocket upgrade path", () => {
expect(
requiresBrowserRequestAuth(
new Request("http://127.0.0.1:8787/future-socket", {
headers: { upgrade: "websocket" },
}),
new URL("http://127.0.0.1:8787/future-socket"),
publicRoute,
),
).toBe(true);
});
it("requires browser auth for every unsafe HTTP method", () => {
expect(
requiresBrowserRequestAuth(
new Request("http://127.0.0.1:8787/future-api", { method: "POST" }),
new URL("http://127.0.0.1:8787/future-api"),
publicRoute,
),
).toBe(true);
});
});
describe("isAuthorizedBrowserRequest", () => {
it.each([
"http://127.0.0.1:8787",
"http://localhost:8787",
"http://[::1]:8787",
])("accepts local dashboard origin %s without a room secret", (origin) => {
expect(
isAuthorizedBrowserRequest(
browserRequest(origin),
new URL("http://127.0.0.1:8787/browser"),
defaultOptions,
),
).toBe(true);
});
it.each([
undefined,
"",
"null",
"not a url",
"http://evil.attacker.example.com",
"http://127.0.0.1:9999",
"https://127.0.0.1:8787",
])("rejects untrusted origin %s", (origin) => {
expect(
isAuthorizedBrowserRequest(
browserRequest(origin),
new URL("http://127.0.0.1:8787/browser"),
defaultOptions,
),
).toBe(false);
});
it.each([
undefined,
"",
"evil.attacker.example.com",
"127.0.0.1:9999",
"localhost:9999",
])("rejects untrusted host %s", (host) => {
expect(
isAuthorizedBrowserRequest(
browserRequest("http://127.0.0.1:8787", {
headers: host === undefined ? { host: "" } : { host },
}),
new URL("http://127.0.0.1:8787/browser"),
defaultOptions,
),
).toBe(false);
});
it("allows explicit wildcard bind host and origin when a room secret is configured", () => {
expect(
isAuthorizedBrowserRequest(
browserRequest("http://0.0.0.0:8787", {
headers: { host: "0.0.0.0:8787" },
}),
new URL("http://0.0.0.0:8787/browser?roomSecret=invite-123"),
{
bindHost: "0.0.0.0",
port: 8787,
publicUrl: "http://127.0.0.1:8787",
roomSecret: "invite-123",
},
),
).toBe(true);
});
it("requires trusted origin, trusted host, and room secret when a room secret is configured", () => {
const options = { ...defaultOptions, roomSecret: "invite-123" };
expect(
isAuthorizedBrowserRequest(
browserRequest("http://127.0.0.1:8787"),
new URL("http://127.0.0.1:8787/browser?roomSecret=invite-123"),
options,
),
).toBe(true);
expect(
isAuthorizedBrowserRequest(
browserRequest("http://127.0.0.1:8787"),
new URL("http://127.0.0.1:8787/browser"),
options,
),
).toBe(false);
expect(
isAuthorizedBrowserRequest(
browserRequest("http://evil.attacker.example.com"),
new URL("http://127.0.0.1:8787/browser?roomSecret=invite-123"),
options,
),
).toBe(false);
expect(
isAuthorizedBrowserRequest(
browserRequest("http://127.0.0.1:8787", {
headers: { host: "evil.attacker.example.com" },
}),
new URL("http://127.0.0.1:8787/browser?roomSecret=invite-123"),
options,
),
).toBe(false);
});
});
describe("isAuthorizedBrowserToDesktopRequest", () => {
it("allows safe public GET routes without an origin", () => {
expect(
isAuthorizedBrowserToDesktopRequest(
new Request("http://127.0.0.1:8787/public"),
new URL("http://127.0.0.1:8787/public"),
defaultOptions,
publicRoute,
),
).toBe(true);
});
it("rejects future WebSocket paths from untrusted origins by default", () => {
expect(
isAuthorizedBrowserToDesktopRequest(
new Request("http://127.0.0.1:8787/future-socket", {
headers: {
host: "127.0.0.1:8787",
origin: "http://evil.attacker.example.com",
upgrade: "websocket",
},
}),
new URL("http://127.0.0.1:8787/future-socket"),
defaultOptions,
publicRoute,
),
).toBe(false);
});
it("rejects future unsafe HTTP routes from untrusted origins by default", () => {
expect(
isAuthorizedBrowserToDesktopRequest(
new Request("http://127.0.0.1:8787/future-api", {
method: "POST",
headers: {
host: "127.0.0.1:8787",
origin: "http://evil.attacker.example.com",
},
}),
new URL("http://127.0.0.1:8787/future-api"),
defaultOptions,
publicRoute,
),
).toBe(false);
});
it("allows future unsafe HTTP routes from trusted origins", () => {
expect(
isAuthorizedBrowserToDesktopRequest(
new Request("http://127.0.0.1:8787/future-api", {
method: "POST",
headers: {
host: "127.0.0.1:8787",
origin: "http://127.0.0.1:8787",
},
}),
new URL("http://127.0.0.1:8787/future-api"),
defaultOptions,
publicRoute,
),
).toBe(true);
});
});
-134
View File
@@ -1,134 +0,0 @@
import { isNonLocalBindHost } from "../options";
export interface BrowserRequestAuthOptions {
bindHost: string;
port: number;
publicUrl: string;
roomSecret?: string;
}
const SAFE_HTTP_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
export type PublicBrowserRoutePredicate = (req: Request, url: URL) => boolean;
function isWebSocketUpgrade(req: Request): boolean {
return req.headers.get("upgrade")?.toLowerCase() === "websocket";
}
function parseOrigin(value: string | null): string | undefined {
const origin = parseHeader(value);
try {
return new URL(origin ?? "").origin;
} catch {
return undefined;
}
}
function parseHeader(value: string | null): string | undefined {
const host = value?.trim().toLowerCase();
return host || undefined;
}
function formatHostForOrigin(host: string): string {
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
}
function isDefaultProtocolPort(protocol: string, port: number): boolean {
return (
(protocol === "http:" && port === 80) ||
(protocol === "https:" && port === 443)
);
}
function originForHost(protocol: string, host: string, port: number): string {
return new URL(`${protocol}//${formatHostForOrigin(host)}:${port}`).origin;
}
function hostHeaderForHost(
protocol: string,
host: string,
port: number,
): string {
const formattedHost = formatHostForOrigin(host).toLowerCase();
return isDefaultProtocolPort(protocol, port)
? formattedHost
: `${formattedHost}:${port}`;
}
export function allowedBrowserOrigins({
bindHost,
port,
publicUrl,
}: BrowserRequestAuthOptions): Set<string> {
const publicUrlParts = new URL(publicUrl);
const origins = new Set<string>();
origins.add(publicUrlParts.origin);
origins.add(originForHost(publicUrlParts.protocol, bindHost, port));
if (!isNonLocalBindHost(bindHost)) {
for (const hostname of ["127.0.0.1", "localhost", "[::1]"]) {
origins.add(originForHost(publicUrlParts.protocol, hostname, port));
}
}
return origins;
}
export function allowedBrowserHosts({
bindHost,
port,
publicUrl,
}: BrowserRequestAuthOptions): Set<string> {
const publicUrlParts = new URL(publicUrl);
const hosts = new Set<string>();
const publicHost = publicUrlParts.host.toLowerCase();
hosts.add(publicHost);
hosts.add(hostHeaderForHost(publicUrlParts.protocol, bindHost, port));
if (!isNonLocalBindHost(bindHost)) {
for (const hostname of ["127.0.0.1", "localhost", "[::1]"]) {
hosts.add(hostHeaderForHost(publicUrlParts.protocol, hostname, port));
}
}
return hosts;
}
export function requiresBrowserRequestAuth(
req: Request,
url: URL,
isPublicBrowserRoute: PublicBrowserRoutePredicate,
): boolean {
if (isWebSocketUpgrade(req)) return true;
if (!SAFE_HTTP_METHODS.has(req.method.toUpperCase())) return true;
return !isPublicBrowserRoute(req, url);
}
export function isAuthorizedBrowserRequest(
req: Request,
url: URL,
options: BrowserRequestAuthOptions,
): boolean {
const host = parseHeader(req.headers.get("host"));
if (!host || !allowedBrowserHosts(options).has(host)) return false;
const origin = parseOrigin(req.headers.get("origin"));
if (!origin || !allowedBrowserOrigins(options).has(origin)) return false;
if (!options.roomSecret) return true;
return url.searchParams.get("roomSecret") === options.roomSecret;
}
export function isAuthorizedBrowserToDesktopRequest(
req: Request,
url: URL,
options: BrowserRequestAuthOptions,
isPublicBrowserRoute: PublicBrowserRoutePredicate,
): boolean {
return (
!requiresBrowserRequestAuth(req, url, isPublicBrowserRoute) ||
isAuthorizedBrowserRequest(req, url, options)
);
}
@@ -1,97 +0,0 @@
import { describe, expect, it } from "vitest";
import { __test__ } from "./connectors";
describe("connector launch command", () => {
it("uses Bun conditions when launching the source CLI from Bun", () => {
expect(
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
execPath: "/Users/test/.bun/bin/bun",
cliPath: "/repo/apps/cli/src/index.ts",
exists: () => true,
}),
).toEqual({
launcher: "/Users/test/.bun/bin/bun",
childArgs: [
"--conditions=development",
"/repo/apps/cli/src/index.ts",
"connect",
"telegram",
"--bot-token",
"token",
],
});
});
it("uses compiled CLI subcommands without Bun flags", () => {
expect(
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
execPath: "/Applications/Cline/bin/cline",
cliPath: "/repo/apps/cli/src/index.ts",
exists: () => true,
}),
).toEqual({
launcher: "/Applications/Cline/bin/cline",
childArgs: ["connect", "telegram", "--bot-token", "token"],
});
});
it("uses Bun conditions when launching the source CLI from Node", () => {
expect(
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
execPath: "/usr/local/bin/node",
cliPath: "/repo/apps/cli/src/index.ts",
exists: () => true,
}),
).toEqual({
launcher: "bun",
childArgs: [
"--conditions=development",
"/repo/apps/cli/src/index.ts",
"connect",
"telegram",
"--bot-token",
"token",
],
});
});
it("detects Windows Node when launching the source CLI", () => {
expect(
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
execPath: "node.exe",
cliPath: "C:\\repo\\apps\\cli\\src\\index.ts",
exists: () => true,
}),
).toEqual({
launcher: "bun",
childArgs: [
"--conditions=development",
"C:\\repo\\apps\\cli\\src\\index.ts",
"connect",
"telegram",
"--bot-token",
"token",
],
});
});
it("strips terminal color codes from connector command failures", () => {
expect(
__test__.normalizeConnectorError(
"\u001B[31merror:\u001B[0m error: unknown option '--conditions=development'",
"connector start failed",
),
).toBe("unknown option '--conditions=development'");
});
it("turns Telegram unauthorized responses into a token validation message", () => {
expect(
__test__.normalizeConnectorError(
"\u001B[31merror:\u001B[0m Telegram getMe failed (401 Unauthorized): Unauthorized",
"connector start failed",
),
).toBe(
"Telegram rejected this bot token. Copy the token from @BotFather and try again.",
);
});
});
-54
View File
@@ -1,54 +0,0 @@
import { describe, expect, it } from "vitest";
import { isWebviewRoute, normalizeWebviewIndexHtml } from "./http";
describe("isWebviewRoute", () => {
it.each([
"/",
"/chat",
"/sessions",
"/models",
"/customizations",
"/rules",
"/hooks",
"/mcp",
"/plugins",
"/skills",
"/agents",
"/tools",
"/marketplace",
"/marketplace/mcp",
"/marketplace/skills",
"/marketplace/plugins",
"/channels",
"/schedules",
"/settings",
"/settings/providers",
])("matches dashboard SPA route %s", (pathname) => {
expect(isWebviewRoute(pathname)).toBe(true);
});
it("does not treat nested marketplace asset requests as SPA routes", () => {
expect(isWebviewRoute("/marketplace/assets/index.js")).toBe(false);
});
});
describe("normalizeWebviewIndexHtml", () => {
it("rewrites relative built asset URLs so deep links can refresh", () => {
expect(
normalizeWebviewIndexHtml(
'<script type="module" src="./assets/index.js"></script><link href="./assets/index.css">',
),
).toBe(
'<script type="module" src="/assets/index.js"></script><link href="/assets/index.css">',
);
});
it("injects the persisted theme bootstrap once", () => {
const normalized = normalizeWebviewIndexHtml(
"<html><head></head><body></body></html>",
);
expect(normalized).toContain('id="cline-hub-theme-bootstrap"');
expect(normalizeWebviewIndexHtml(normalized)).toBe(normalized);
});
});
@@ -1,870 +0,0 @@
import { createHash } from "node:crypto";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildMarketplaceMcpInput,
fetchMarketplaceCatalog,
installMarketplaceEntry,
installMarketplaceEntryForDesktopCommand,
listMarketplaceInstalledEntries,
uninstallLocalPrimitive,
uninstallMarketplaceEntry,
uninstallMarketplaceEntryForDesktopCommand,
} from "./marketplace";
describe("marketplace installer", () => {
const originalWrapperPath = process.env.CLINE_WRAPPER_PATH;
const originalClineDir = process.env.CLINE_DIR;
const originalHome = process.env.HOME;
const originalMcpSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH;
afterEach(() => {
if (originalWrapperPath === undefined) {
delete process.env.CLINE_WRAPPER_PATH;
} else {
process.env.CLINE_WRAPPER_PATH = originalWrapperPath;
}
if (originalClineDir === undefined) {
delete process.env.CLINE_DIR;
} else {
process.env.CLINE_DIR = originalClineDir;
}
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
if (originalMcpSettingsPath === undefined) {
delete process.env.CLINE_MCP_SETTINGS_PATH;
} else {
process.env.CLINE_MCP_SETTINGS_PATH = originalMcpSettingsPath;
}
vi.restoreAllMocks();
});
it("maps remote MCP catalog args to the hub MCP upsert shape", () => {
expect(
buildMarketplaceMcpInput([
"context7",
"--transport",
"http",
"https://mcp.context7.com/mcp",
]),
).toEqual({
name: "context7",
transportType: "streamableHttp",
url: "https://mcp.context7.com/mcp",
disabled: false,
});
});
it("maps stdio MCP catalog args to command and args", () => {
expect(
buildMarketplaceMcpInput(["filesystem", "npx", "-y", "server", "/tmp"]),
).toEqual({
name: "filesystem",
transportType: "stdio",
command: "npx",
args: ["-y", "server", "/tmp"],
disabled: false,
});
});
it("preserves server flags after stdio MCP command args begin", () => {
expect(
buildMarketplaceMcpInput([
"search",
"npx",
"-y",
"server",
"--transport",
"stdio",
]),
).toEqual({
name: "search",
transportType: "stdio",
command: "npx",
args: ["-y", "server", "--transport", "stdio"],
disabled: false,
});
});
it("runs skills globally for Cline without prompts", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
process.env.HOME = homeDir;
const spawnCommand = vi.fn(async () => {
mkdirSync(join(homeDir, ".agents", "skills", "web-design-guidelines"), {
recursive: true,
});
writeFileSync(
join(homeDir, ".agents", "skills", "web-design-guidelines", "SKILL.md"),
"---\nname: web-design-guidelines\n---\n",
);
return {
exitCode: 0,
stdout: "installed",
stderr: "",
};
});
await installMarketplaceEntry(
{
entry: {
id: "web-design-guidelines",
type: "skill",
name: "Web Design Guidelines",
install: {
args: [
"vercel-labs/agent-skills",
"--skill",
"web-design-guidelines",
],
},
},
},
{ spawnCommand },
);
expect(spawnCommand).toHaveBeenCalledWith("npx", [
"-y",
"skills@latest",
"add",
"vercel-labs/agent-skills",
"--skill",
"web-design-guidelines",
"-g",
"-a",
"cline",
"-y",
]);
});
it("skips skill install commands when the global skill already exists", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
process.env.HOME = homeDir;
mkdirSync(join(homeDir, ".agents", "skills", "cline-sdk"), {
recursive: true,
});
writeFileSync(
join(homeDir, ".agents", "skills", "cline-sdk", "SKILL.md"),
"---\nname: cline-sdk\n---\n",
);
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: "",
stderr: "",
}));
await expect(
installMarketplaceEntry(
{
entry: {
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
},
{ spawnCommand },
),
).resolves.toMatchObject({
status: "installed",
message: "Cline SDK is already installed.",
});
expect(spawnCommand).not.toHaveBeenCalled();
});
it("reports Cline global skills as marketplace-installed", () => {
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-cline-"));
process.env.CLINE_DIR = clineDir;
mkdirSync(join(clineDir, "skills", "cline-sdk"), {
recursive: true,
});
writeFileSync(
join(clineDir, "skills", "cline-sdk", "SKILL.md"),
"---\nname: cline-sdk\n---\n",
);
expect(
listMarketplaceInstalledEntries({
entries: [
{
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
],
}),
).toEqual({ installedKeys: ["skill:cline-sdk"] });
});
it("accepts skill installs that create Cline global skills", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
const clineDir = join(homeDir, ".cline");
process.env.HOME = homeDir;
process.env.CLINE_DIR = clineDir;
const spawnCommand = vi.fn(async () => {
mkdirSync(join(clineDir, "skills", "cline-sdk"), {
recursive: true,
});
writeFileSync(
join(clineDir, "skills", "cline-sdk", "SKILL.md"),
"---\nname: cline-sdk\n---\n",
);
return {
exitCode: 0,
stdout: "installed",
stderr: "",
};
});
await expect(
installMarketplaceEntry(
{
entry: {
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
},
{ spawnCommand },
),
).resolves.toMatchObject({
status: "installed",
message: "Installed Cline SDK globally for Cline.",
});
});
it("removes Cline global marketplace skills without prompts", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
process.env.HOME = homeDir;
const skillDir = join(homeDir, ".agents", "skills", "cline-sdk");
mkdirSync(skillDir, { recursive: true });
writeFileSync(join(skillDir, "SKILL.md"), "---\nname: cline-sdk\n---\n");
const spawnCommand = vi.fn(async () => {
rmSync(skillDir, { recursive: true, force: true });
return {
exitCode: 0,
stdout: "removed",
stderr: "",
};
});
await expect(
uninstallMarketplaceEntry(
{
entry: {
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
},
{ spawnCommand },
),
).resolves.toMatchObject({
status: "uninstalled",
message: "Uninstalled Cline SDK.",
});
expect(spawnCommand).toHaveBeenCalledWith("npx", [
"-y",
"skills@latest",
"remove",
"cline-sdk",
"-g",
"-a",
"cline",
"-y",
]);
});
it("does not report project-local skills as marketplace-installed globals", () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
process.env.HOME = homeDir;
expect(
listMarketplaceInstalledEntries(
{
entries: [
{
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
],
},
{
skills: [
{
id: "cline-sdk",
name: "cline-sdk",
path: "/workspace/project/.agents/skills/cline-sdk/SKILL.md",
},
],
},
),
).toEqual({ installedKeys: [] });
});
it("rejects skill installs that exit zero but report failure", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
process.env.HOME = homeDir;
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: "Failed to install 1",
stderr: "",
}));
await expect(
installMarketplaceEntry(
{
entry: {
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
},
{ spawnCommand },
),
).rejects.toThrow("Skill install failed");
});
it("redacts common secret formats from failed install output", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
process.env.HOME = homeDir;
const spawnCommand = vi.fn(async () => ({
exitCode: 1,
stdout:
"Authorization: Bearer stdout-token\napi key stdout-key\nOPENAI_API_KEY=compound-key",
stderr:
"TOKEN=stderr-token\npassword is stderr-password\nANTHROPIC_SECRET_KEY=anthropic-secret",
}));
let message = "";
try {
await installMarketplaceEntry(
{
entry: {
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
},
{ spawnCommand },
);
} catch (error) {
message = error instanceof Error ? error.message : String(error);
}
expect(message).toContain("Authorization: [redacted]");
expect(message).toContain("api key [redacted]");
expect(message).toContain("OPENAI_API_KEY=[redacted]");
expect(message).toContain("TOKEN=[redacted]");
expect(message).toContain("password is [redacted]");
expect(message).toContain("ANTHROPIC_SECRET_KEY=[redacted]");
expect(message).not.toContain("stdout-token");
expect(message).not.toContain("stdout-key");
expect(message).not.toContain("compound-key");
expect(message).not.toContain("stderr-token");
expect(message).not.toContain("stderr-password");
expect(message).not.toContain("anthropic-secret");
});
it("rejects skill installs before spawning when the global skill directory is not writable", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
process.env.HOME = homeDir;
mkdirSync(join(homeDir, ".agents"), { recursive: true });
writeFileSync(join(homeDir, ".agents", "skills"), "");
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: "",
stderr: "",
}));
await expect(
installMarketplaceEntry(
{
entry: {
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
},
{ spawnCommand },
),
).rejects.toThrow(
"Cannot install skill globally because ~/.agents/skills is not writable",
);
expect(spawnCommand).not.toHaveBeenCalled();
});
it("rejects skill installs that do not create a global skill", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
process.env.HOME = homeDir;
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: "Installation complete",
stderr: "",
}));
await expect(
installMarketplaceEntry(
{
entry: {
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
},
{ spawnCommand },
),
).rejects.toThrow("was not found in Cline's global skills directories");
});
it("runs official plugin installs through the current Cline CLI", async () => {
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: JSON.stringify({ installPath: "/tmp/plugin" }),
stderr: "",
}));
await installMarketplaceEntry(
{
entry: {
id: "marketplace-test-plugin",
type: "plugin",
name: "Marketplace Test Plugin",
install: { args: ["marketplace-test-plugin"] },
},
},
{ spawnCommand },
);
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
"plugin",
"install",
"marketplace-test-plugin",
"--json",
]);
});
it("runs official plugin uninstalls through the current Cline CLI", async () => {
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: JSON.stringify({
name: "goal",
installPath: "/tmp/plugin",
removedPaths: ["/tmp/plugin"],
entryPaths: [],
}),
stderr: "",
}));
await expect(
uninstallMarketplaceEntry(
{
entry: {
id: "goal",
type: "plugin",
name: "Goal",
install: { args: ["goal"] },
},
},
{ spawnCommand },
),
).resolves.toMatchObject({
status: "uninstalled",
message: "Uninstalled Goal.",
});
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
"plugin",
"uninstall",
"goal",
"--json",
]);
});
it("resolves desktop installs from the server catalog instead of browser-sent args", async () => {
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: JSON.stringify({ installPath: "/tmp/plugin" }),
stderr: "",
}));
await installMarketplaceEntryForDesktopCommand(
{
entry: {
id: "marketplace-test-plugin",
type: "plugin",
name: "Tampered",
install: { args: ["malicious-source"] },
},
},
{
spawnCommand,
loadCatalog: async () => ({
entries: [
{
id: "marketplace-test-plugin",
type: "plugin",
name: "Marketplace Test Plugin",
install: { args: ["marketplace-test-plugin"] },
},
],
}),
},
);
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
"plugin",
"install",
"marketplace-test-plugin",
"--json",
]);
});
it("resolves desktop uninstalls from the server catalog instead of browser-sent args", async () => {
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: JSON.stringify({
name: "goal",
installPath: "/tmp/plugin",
removedPaths: ["/tmp/plugin"],
entryPaths: [],
}),
stderr: "",
}));
await uninstallMarketplaceEntryForDesktopCommand(
{
entry: {
id: "goal",
type: "plugin",
name: "Tampered",
install: { args: ["malicious-source"] },
},
},
{
spawnCommand,
loadCatalog: async () => ({
entries: [
{
id: "goal",
type: "plugin",
name: "Goal",
install: { args: ["goal"] },
},
],
}),
},
);
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
"plugin",
"uninstall",
"goal",
"--json",
]);
});
it("uninstalls MCP marketplace entries from Cline MCP settings", async () => {
const settingsPath = join(
mkdtempSync(join(tmpdir(), "cline-marketplace-mcp-")),
"cline_mcp_settings.json",
);
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
writeFileSync(
settingsPath,
JSON.stringify(
{
mcpServers: {
context7: {
transport: {
type: "streamableHttp",
url: "https://mcp.context7.com/mcp",
},
},
},
},
null,
2,
),
);
await expect(
uninstallMarketplaceEntry({
entry: {
id: "context7",
type: "mcp",
name: "Context7",
install: {
args: [
"context7",
"--transport",
"http",
"https://mcp.context7.com/mcp",
],
},
},
}),
).resolves.toMatchObject({
status: "uninstalled",
message: "Uninstalled Context7.",
});
expect(
listMarketplaceInstalledEntries({
entries: [
{
id: "context7",
type: "mcp",
name: "Context7",
install: {
args: [
"context7",
"--transport",
"http",
"https://mcp.context7.com/mcp",
],
},
},
],
}),
).toEqual({ installedKeys: [] });
});
it("uninstalls local MCP servers by name", async () => {
const settingsPath = join(
mkdtempSync(join(tmpdir(), "cline-local-mcp-")),
"cline_mcp_settings.json",
);
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
writeFileSync(
settingsPath,
JSON.stringify(
{
mcpServers: {
context7: {
transport: {
type: "streamableHttp",
url: "https://mcp.context7.com/mcp",
},
},
},
},
null,
2,
),
);
await expect(
uninstallLocalPrimitive({
type: "mcp",
id: "context7",
name: "context7",
}),
).resolves.toMatchObject({
status: "uninstalled",
message: "Uninstalled context7.",
});
expect(readFileSync(settingsPath, "utf8")).not.toContain("context7");
});
it("uninstalls local skills by removing their configured skill directory", async () => {
const workspaceRoot = mkdtempSync(join(tmpdir(), "cline-local-skill-"));
const skillDir = join(workspaceRoot, ".cline", "skills", "review");
mkdirSync(skillDir, { recursive: true });
const skillPath = join(skillDir, "SKILL.md");
writeFileSync(skillPath, "---\nname: review\n---\nReview changes.");
await expect(
uninstallLocalPrimitive(
{
type: "skill",
id: "review",
name: "Review",
path: skillPath,
},
{ workspaceRoot },
),
).resolves.toMatchObject({
status: "uninstalled",
message: "Uninstalled Review.",
});
expect(existsSync(skillDir)).toBe(false);
});
it("reports official plugin marketplace entries installed from Cline home", () => {
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-test-"));
process.env.CLINE_DIR = clineDir;
const sourceKey =
"official:https://github.com/cline/plugins.git#plugins/goal";
const hash = createHash("sha256")
.update(sourceKey)
.digest("hex")
.slice(0, 12);
mkdirSync(
join(clineDir, "plugins", "_installed", "official", `goal-${hash}`),
{
recursive: true,
},
);
expect(
listMarketplaceInstalledEntries({
entries: [
{
id: "goal",
type: "plugin",
name: "Goal",
install: { args: ["goal"] },
},
],
}),
).toEqual({ installedKeys: ["plugin:goal"] });
});
it("does not report plugin inventory substring matches as installed", () => {
process.env.CLINE_DIR = mkdtempSync(
join(tmpdir(), "cline-marketplace-test-"),
);
expect(
listMarketplaceInstalledEntries(
{
entries: [
{
id: "goal",
type: "plugin",
name: "Goal",
install: { args: ["goal"] },
},
],
},
{
plugins: [
{
name: "goal-helper",
path: "/workspace/.cline/plugins/goal-helper/index.ts",
},
],
},
),
).toEqual({ installedKeys: [] });
});
it("skips invalid marketplace entries during installed-status checks", () => {
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-test-"));
process.env.CLINE_DIR = clineDir;
const sourceKey =
"official:https://github.com/cline/plugins.git#plugins/goal";
const hash = createHash("sha256")
.update(sourceKey)
.digest("hex")
.slice(0, 12);
mkdirSync(
join(clineDir, "plugins", "_installed", "official", `goal-${hash}`),
{
recursive: true,
},
);
expect(
listMarketplaceInstalledEntries({
entries: [
{
id: "broken-mcp",
type: "mcp",
name: "Broken MCP",
install: {
args: [
"broken-mcp",
"--transport",
"ws",
"https://example.com/mcp",
],
},
},
{
id: "goal",
type: "plugin",
name: "Goal",
install: { args: ["goal"] },
},
],
}),
).toEqual({ installedKeys: ["plugin:goal"] });
});
it("rejects invalid marketplace entries before spawning commands", async () => {
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: "",
stderr: "",
}));
await expect(
installMarketplaceEntry(
{
entry: {
id: "bad",
type: "skill",
install: { args: [] },
},
},
{ spawnCommand },
),
).rejects.toThrow("marketplace install args are required");
expect(spawnCommand).not.toHaveBeenCalled();
});
it("fetches the marketplace catalog through the server helper", async () => {
const fetchImpl = vi.fn(async () => {
return new Response(JSON.stringify({ version: 1, entries: [] }), {
headers: { "content-type": "application/json" },
});
});
await expect(fetchMarketplaceCatalog(fetchImpl)).resolves.toEqual({
version: 1,
entries: [],
});
expect(fetchImpl).toHaveBeenCalledWith(
"https://cline.github.io/marketplace/catalog.json",
{ headers: { Accept: "application/json" } },
);
});
it("surfaces marketplace catalog upstream failures", async () => {
const fetchImpl = vi.fn(async () => {
return new Response("nope", {
status: 503,
statusText: "Service Unavailable",
});
});
await expect(fetchMarketplaceCatalog(fetchImpl)).rejects.toThrow(
"Failed to fetch marketplace catalog: 503 Service Unavailable",
);
});
});
File diff suppressed because it is too large Load Diff
@@ -1,70 +0,0 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { listUserInstructionConfigs } from "./user-instructions";
describe("listUserInstructionConfigs", () => {
const tempRoots: string[] = [];
const envSnapshot = {
CLINE_GLOBAL_SETTINGS_PATH: process.env.CLINE_GLOBAL_SETTINGS_PATH,
CLINE_MCP_SETTINGS_PATH: process.env.CLINE_MCP_SETTINGS_PATH,
};
afterEach(async () => {
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 })),
);
tempRoots.length = 0;
});
it("uses the package name for package-backed plugin entries", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cline-hub-config-"));
tempRoots.push(tempRoot);
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(tempRoot, "settings.json");
process.env.CLINE_MCP_SETTINGS_PATH = join(tempRoot, "mcp.json");
const packageDir = join(
tempRoot,
".cline",
"plugins",
"_installed",
"git",
"github.com",
"demo",
"package",
);
await mkdir(packageDir, { recursive: true });
const pluginPath = join(packageDir, "index.ts");
await writeFile(
join(packageDir, "package.json"),
JSON.stringify(
{
name: "cline-sdk-portable-agents",
cline: {
plugins: [{ paths: ["./index.ts"] }],
},
},
null,
2,
),
);
await writeFile(pluginPath, "export default {};\n");
const data = await listUserInstructionConfigs(tempRoot);
const plugins = data.plugins as Array<{ name: string; path: string }>;
const plugin = plugins.find((item) => item.path === pluginPath);
expect(plugin?.name).toBe("cline-sdk-portable-agents");
});
});
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Ebene_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 466.73 487.04">
<!-- Generator: Adobe Illustrator 29.8.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 2) -->
<path d="M463.6,275.08l-29.26-58.75v-33.83c0-56.08-45.01-101.5-100.53-101.5h-50.01c3.62-7.43,5.61-15.79,5.61-24.61,0-31.17-25.08-56.39-56.07-56.39s-56.07,25.22-56.07,56.39c0,8.82,1.99,17.17,5.61,24.61h-50.01c-55.51,0-100.52,45.42-100.52,101.5v33.83l-29.87,58.59c-3.01,5.9-3.01,12.92,0,18.81l29.87,57.93v33.83c0,56.08,45.01,101.5,100.52,101.5h200.95c55.51,0,100.53-45.42,100.53-101.5v-33.83l29.21-58.13c2.9-5.79,2.9-12.61.05-18.46ZM202.75,322.96c0,25.48-20.54,46.14-45.88,46.14s-45.88-20.66-45.88-46.14v-82.02c0-25.48,20.54-46.14,45.88-46.14s45.88,20.66,45.88,46.14v82.02ZM350.58,322.96c0,25.48-20.54,46.14-45.88,46.14s-45.88-20.66-45.88-46.14v-82.02c0-25.48,20.54-46.14,45.88-46.14s45.88,20.66,45.88,46.14v82.02Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 957 B

File diff suppressed because it is too large Load Diff
@@ -1,170 +0,0 @@
import { cjk } from "@streamdown/cjk";
import type { MermaidConfig } from "mermaid";
import type { ComponentProps, ReactNode } from "react";
import { isValidElement, memo } from "react";
import {
type Components,
type DiagramPlugin,
Streamdown,
type StreamdownProps,
} from "streamdown";
import {
CodeBlock,
CodeBlockActions,
CodeBlockCopyButton,
CodeBlockFilename,
CodeBlockHeader,
CodeBlockTitle,
} from "@/components/ai-elements/code-block";
import { cn } from "@/lib/utils";
type MarkdownCodeProps = ComponentProps<"code"> & {
"data-block"?: boolean | string;
node?: {
properties?: {
metastring?: string;
};
};
};
const LANGUAGE_CLASS_PATTERN = /(?:^|\s)language-([^\s]+)/;
const START_LINE_PATTERN = /startLine=(\d+)/;
const NO_LINE_NUMBERS_PATTERN = /\bnoLineNumbers\b/;
function codeText(children: ReactNode): string {
if (typeof children === "string" || typeof children === "number") {
return String(children);
}
if (Array.isArray(children)) {
return children.map(codeText).join("");
}
if (isValidElement<{ children?: ReactNode }>(children)) {
return codeText(children.props.children);
}
return "";
}
const MarkdownCode = ({
children,
className,
node,
"data-block": dataBlock,
...props
}: MarkdownCodeProps) => {
const language = className?.match(LANGUAGE_CLASS_PATTERN)?.[1] ?? "text";
if (!dataBlock) {
return (
<code
className={cn(
"rounded bg-muted px-1.5 py-0.5 font-mono text-sm",
className,
)}
{...props}
>
{children}
</code>
);
}
const meta = node?.properties?.metastring;
const startLineMatch = meta?.match(START_LINE_PATTERN);
const startLine = startLineMatch ? Number.parseInt(startLineMatch[1], 10) : 1;
const showLineNumbers = meta ? !NO_LINE_NUMBERS_PATTERN.test(meta) : true;
return (
<CodeBlock
code={codeText(children)}
data-start-line={startLine > 1 ? startLine : undefined}
language={language}
showLineNumbers={showLineNumbers}
>
<CodeBlockHeader>
<CodeBlockTitle>
<CodeBlockFilename>{language}</CodeBlockFilename>
</CodeBlockTitle>
<CodeBlockActions>
<CodeBlockCopyButton />
</CodeBlockActions>
</CodeBlockHeader>
</CodeBlock>
);
};
const markdownComponents = {
code: MarkdownCode,
} satisfies Components;
const DEFAULT_MERMAID_CONFIG = {
fontFamily: "monospace",
securityLevel: "strict",
startOnLoad: false,
suppressErrorRendering: true,
theme: "default",
} satisfies MermaidConfig;
interface LazyMermaidInstance {
initialize: (config: MermaidConfig) => void;
render: (
id: string,
source: string,
) => Promise<{
svg: string;
}>;
}
function createLazyMermaidPlugin(): DiagramPlugin {
let config: MermaidConfig = DEFAULT_MERMAID_CONFIG;
let initialized = false;
const instance: LazyMermaidInstance = {
initialize(nextConfig: MermaidConfig) {
config = { ...DEFAULT_MERMAID_CONFIG, ...config, ...nextConfig };
initialized = false;
},
async render(id: string, source: string) {
const mermaidModule = await import("mermaid");
const mermaid = mermaidModule.default;
if (!initialized) {
mermaid.initialize(config);
initialized = true;
}
return mermaid.render(id, source);
},
};
return {
getMermaid(nextConfig?: MermaidConfig) {
if (nextConfig) {
instance.initialize(nextConfig);
}
return instance;
},
language: "mermaid",
name: "mermaid",
type: "diagram",
};
}
const streamdownPlugins = { cjk, mermaid: createLazyMermaidPlugin() };
export type HubStreamdownProps = StreamdownProps;
export const HubStreamdown = memo(
({ className, components, ...props }: HubStreamdownProps) => {
const mergedComponents = components
? { ...markdownComponents, ...components }
: markdownComponents;
return (
<Streamdown
className={className}
components={mergedComponents}
plugins={streamdownPlugins}
{...props}
/>
);
},
);
HubStreamdown.displayName = "HubStreamdown";
@@ -1,27 +0,0 @@
"use client";
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox";
import { CheckIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-lg border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };
File diff suppressed because it is too large Load Diff
@@ -1,111 +0,0 @@
import type { ComponentType, ReactNode } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
type PageFrameProps = {
children: ReactNode;
className?: string;
contentClassName?: string;
};
export function PageFrame({
children,
className,
contentClassName,
}: PageFrameProps) {
return (
<ScrollArea className="h-full">
<div
className={cn(
"px-18 py-10 max-[1200px]:px-8 max-[720px]:px-4 max-[720px]:py-5",
className,
)}
>
<div className={cn("max-w-[86rem]", contentClassName)}>{children}</div>
</div>
</ScrollArea>
);
}
type PageHeaderProps = {
actions?: ReactNode;
className?: string;
description?: ReactNode;
icon?: ComponentType<{ className?: string }>;
meta?: ReactNode;
title: ReactNode;
};
export function PageHeader({
actions,
className,
description,
icon: Icon,
meta,
title,
}: PageHeaderProps) {
return (
<section
className={cn(
"mb-8 flex items-start justify-between gap-6 max-[860px]:flex-col max-[860px]:items-stretch",
className,
)}
>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-3">
{Icon ? <Icon className="size-8 shrink-0 text-primary" /> : null}
<h1 className="truncate text-[32px] font-semibold leading-[1.15] tracking-normal text-foreground">
{title}
</h1>
{meta}
</div>
{description ? (
<p className="mt-3 max-w-2xl text-[15px] leading-6 text-muted-foreground">
{description}
</p>
) : null}
</div>
{actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2 max-[860px]:justify-start">
{actions}
</div>
) : null}
</section>
);
}
type PageEmptyStateProps = {
children: ReactNode;
className?: string;
};
export function PageEmptyState({ children, className }: PageEmptyStateProps) {
return (
<div
className={cn(
"rounded-lg border border-dashed border-border bg-card px-5 py-4 text-sm leading-6 text-muted-foreground",
className,
)}
>
{children}
</div>
);
}
type CommandBadgeProps = {
children: ReactNode;
className?: string;
};
export function CommandBadge({ children, className }: CommandBadgeProps) {
return (
<span
className={cn(
"rounded-md border border-border bg-background px-2 py-0.5 font-mono text-xs text-muted-foreground",
className,
)}
>
{children}
</span>
);
}
@@ -1,711 +0,0 @@
"use client";
import type {
ClineAccountBalance,
ClineAccountOrganization,
ClineAccountOrganizationBalance,
ClineAccountOrganizationUsageTransaction,
ClineAccountPaymentTransaction,
ClineAccountUsageTransaction,
ClineAccountUser,
} from "@cline/core";
import {
AlertCircle,
Building,
CreditCard,
ExternalLink,
Loader2,
LogIn,
LogOut,
Plus,
Receipt,
RefreshCw,
UserCircleIcon,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { desktopClient } from "@/lib/desktop-client";
import { cn } from "@/lib/utils";
import { PageFrame, PageHeader } from "../page-layout";
function normalizeAccountViewError(error: unknown): Error {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("unsupported desktop command: cline_account")) {
return new Error(
"The desktop sidecar is running an older build that does not support account commands. Restart the sidecar or reload the app, then try again.",
);
}
return error instanceof Error ? error : new Error(message);
}
function isAccountAuthError(message: string): boolean {
const normalized = message.toLowerCase();
return (
normalized.includes("no cline account auth token found") ||
normalized.includes("requires re-authentication") ||
normalized.includes("auth token") ||
normalized.includes("unauthorized")
);
}
// ---------------------------------------------------------------------------
// Data fetching helpers via sidecar command
// ---------------------------------------------------------------------------
async function fetchAccountUser(): Promise<ClineAccountUser> {
return await desktopClient.invoke<ClineAccountUser>("cline_account", {
action: "clineAccount",
operation: "fetchMe",
});
}
async function fetchAccountBalance(): Promise<ClineAccountBalance> {
return await desktopClient.invoke<ClineAccountBalance>("cline_account", {
action: "clineAccount",
operation: "fetchBalance",
});
}
async function fetchAccountOrganizations(): Promise<
ClineAccountOrganization[]
> {
return await desktopClient.invoke<ClineAccountOrganization[]>(
"cline_account",
{
action: "clineAccount",
operation: "fetchUserOrganizations",
},
);
}
async function fetchOrganizationBalance(
organizationId: string,
): Promise<ClineAccountOrganizationBalance> {
return await desktopClient.invoke<ClineAccountOrganizationBalance>(
"cline_account",
{
action: "clineAccount",
operation: "fetchOrganizationBalance",
organizationId,
},
);
}
async function fetchUsageTransactions(): Promise<
ClineAccountUsageTransaction[]
> {
return await desktopClient.invoke<ClineAccountUsageTransaction[]>(
"cline_account",
{
action: "clineAccount",
operation: "fetchUsageTransactions",
},
);
}
async function fetchOrganizationUsageTransactions(
organizationId: string,
memberId?: string,
): Promise<ClineAccountOrganizationUsageTransaction[]> {
return await desktopClient.invoke<ClineAccountOrganizationUsageTransaction[]>(
"cline_account",
{
action: "clineAccount",
operation: "fetchOrganizationUsageTransactions",
organizationId,
...(memberId?.trim() ? { memberId: memberId.trim() } : {}),
},
);
}
async function fetchPaymentTransactions(): Promise<
ClineAccountPaymentTransaction[]
> {
return await desktopClient.invoke<ClineAccountPaymentTransaction[]>(
"cline_account",
{
action: "clineAccount",
operation: "fetchPaymentTransactions",
},
);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function AccountView() {
const [activeTab, setActiveTab] = useState<"overview" | "usage" | "billing">(
"overview",
);
const [accountActionPending, setAccountActionPending] = useState<
"sign-in" | "sign-out" | null
>(null);
// Overview data
const [user, setUser] = useState<ClineAccountUser | null>(null);
const [balance, setBalance] = useState<ClineAccountBalance | null>(null);
const [organizationBalance, setOrganizationBalance] =
useState<ClineAccountOrganizationBalance | null>(null);
const [organizations, setOrganizations] = useState<
ClineAccountOrganization[]
>([]);
const [overviewLoading, setOverviewLoading] = useState(true);
const [overviewError, setOverviewError] = useState<string | null>(null);
// Usage data
const [usageTransactions, setUsageTransactions] = useState<
ClineAccountUsageTransaction[]
>([]);
const [usageLoading, setUsageLoading] = useState(false);
const [usageError, setUsageError] = useState<string | null>(null);
const [usageLoaded, setUsageLoaded] = useState(false);
const usageGenerationRef = useRef(0);
// Billing data
const [paymentTransactions, setPaymentTransactions] = useState<
ClineAccountPaymentTransaction[]
>([]);
const [billingLoading, setBillingLoading] = useState(false);
const [billingError, setBillingError] = useState<string | null>(null);
const [billingLoaded, setBillingLoaded] = useState(false);
const activeOrganization = organizations.find((org) => org.active) ?? null;
const resetAccountData = useCallback(() => {
setUser(null);
setBalance(null);
setOrganizationBalance(null);
setOrganizations([]);
setUsageTransactions([]);
setUsageLoaded(false);
setUsageError(null);
setPaymentTransactions([]);
setBillingLoaded(false);
setBillingError(null);
}, []);
// -- Overview fetch --
const loadOverview = useCallback(async () => {
setOverviewLoading(true);
setOverviewError(null);
try {
const [userData, balanceData, orgsData] = await Promise.all([
fetchAccountUser(),
fetchAccountBalance(),
fetchAccountOrganizations(),
]);
const nextActiveOrganization =
orgsData.find((organization) => organization.active) ?? null;
const organizationBalanceData = nextActiveOrganization
? await fetchOrganizationBalance(nextActiveOrganization.organizationId)
: null;
setUser(userData);
setBalance(balanceData);
setOrganizationBalance(organizationBalanceData);
setOrganizations(orgsData);
} catch (err) {
resetAccountData();
const message = normalizeAccountViewError(err).message;
setOverviewError(message);
} finally {
setOverviewLoading(false);
}
}, [resetAccountData]);
useEffect(() => {
void loadOverview();
}, [loadOverview]);
const signIn = async () => {
setAccountActionPending("sign-in");
setOverviewError(null);
try {
await desktopClient.invoke("run_provider_oauth_login", {
provider: "cline",
});
await loadOverview();
setActiveTab("overview");
} catch (err) {
const message = normalizeAccountViewError(err).message;
setOverviewError(message);
resetAccountData();
} finally {
setAccountActionPending(null);
}
};
const signOut = async () => {
setAccountActionPending("sign-out");
try {
await desktopClient.invoke("save_provider_settings", {
provider: "cline",
api_key: "",
settings: {
auth: {
accessToken: "",
refreshToken: "",
accountId: "",
},
},
});
resetAccountData();
setActiveTab("overview");
setOverviewError("No Cline account auth token found");
} catch (err) {
const message = normalizeAccountViewError(err).message;
setOverviewError(message);
} finally {
setAccountActionPending(null);
}
};
// -- Usage fetch (lazy on tab switch) --
const loadUsage = useCallback(async () => {
const generation = usageGenerationRef.current;
setUsageLoading(true);
setUsageError(null);
try {
const data = activeOrganization
? await fetchOrganizationUsageTransactions(
activeOrganization.organizationId,
activeOrganization.memberId,
)
: await fetchUsageTransactions();
if (usageGenerationRef.current !== generation) return;
setUsageTransactions(data);
setUsageLoaded(true);
} catch (err) {
if (usageGenerationRef.current !== generation) return;
const message = normalizeAccountViewError(err).message;
setUsageError(message);
} finally {
if (usageGenerationRef.current === generation) {
setUsageLoading(false);
}
}
}, [activeOrganization]);
// biome-ignore lint/correctness/useExhaustiveDependencies: we need to reset usage state when the organization changes
useEffect(() => {
usageGenerationRef.current += 1;
setUsageTransactions([]);
setUsageLoaded(false);
setUsageError(null);
}, [activeOrganization?.organizationId]);
useEffect(() => {
if (activeTab === "usage" && !usageLoaded) {
void loadUsage();
}
}, [activeTab, usageLoaded, loadUsage]);
// -- Billing fetch (lazy on tab switch) --
const loadBilling = useCallback(async () => {
setBillingLoading(true);
setBillingError(null);
try {
const data = await fetchPaymentTransactions();
setPaymentTransactions(data);
setBillingLoaded(true);
} catch (err) {
const message = normalizeAccountViewError(err).message;
setBillingError(message);
} finally {
setBillingLoading(false);
}
}, []);
useEffect(() => {
if (activeTab === "billing" && !billingLoaded) {
void loadBilling();
}
}, [activeTab, billingLoaded, loadBilling]);
// -- Formatters --
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
};
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
});
};
const formatCreditBalance = (value: number, decimalPlaces = 2) => {
return new Intl.NumberFormat("en-US", {
minimumFractionDigits: decimalPlaces,
maximumFractionDigits: decimalPlaces,
}).format(value / 1_000_000);
};
const displayedBalance = activeOrganization
? (organizationBalance?.balance ?? balance?.balance ?? null)
: (balance?.balance ?? null);
const tabs = ["overview", "usage", "billing"] as const;
// -- Shared error / loading UI --
const renderError = (message: string, onRetry: () => void) => (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
<AlertCircle className="h-8 w-8 text-destructive" />
<p className="text-sm text-muted-foreground max-w-md">{message}</p>
<button
type="button"
onClick={onRetry}
className="flex items-center gap-2 rounded-lg border border-border px-3.5 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<RefreshCw className="h-4 w-4" />
Retry
</button>
</div>
);
const renderSignedOut = () => (
<div className="rounded-lg border border-border bg-card p-6">
<div className="mx-auto flex max-w-xl flex-col items-center gap-4 py-8 text-center">
<div className="flex size-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
<UserCircleIcon className="h-6 w-6" />
</div>
<div>
<h3 className="text-lg font-semibold text-foreground">
Sign in to Cline
</h3>
<p className="mt-2 text-sm text-muted-foreground">
Connect your Cline account to review credits, usage, billing, and
organization details from Cline Hub.
</p>
</div>
<div className="flex flex-wrap items-center justify-center gap-2">
<Button
disabled={accountActionPending !== null}
onClick={() => void signIn()}
type="button"
>
{accountActionPending === "sign-in" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<LogIn className="h-4 w-4" />
)}
{accountActionPending === "sign-in" ? "Signing in" : "Sign in"}
</Button>
<a
className="inline-flex h-9 items-center gap-2 rounded-lg border border-border px-3.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
href="https://app.cline.bot"
rel="noopener noreferrer"
target="_blank"
>
Create account
<ExternalLink className="h-4 w-4" />
</a>
</div>
</div>
</div>
);
const renderLoading = () => (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
return (
<PageFrame>
<PageHeader
description="Review account, usage, billing, and organization details."
title="Account"
/>
{/* Tabs */}
<div className="mb-6 flex items-center gap-0 border-b border-border">
{tabs.map((tab) => {
const disabled = !user && tab !== "overview";
return (
<button
disabled={disabled}
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={cn(
"relative px-4 py-2.5 text-sm font-medium capitalize transition-colors",
activeTab === tab
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
disabled &&
"cursor-not-allowed opacity-45 hover:text-muted-foreground",
)}
>
{tab}
{activeTab === tab && (
<span className="absolute inset-x-0 -bottom-px h-0.5 bg-foreground" />
)}
</button>
);
})}
</div>
{/* Overview Tab */}
{activeTab === "overview" && (
<div className="flex flex-col gap-6">
{overviewLoading && renderLoading()}
{overviewError &&
(isAccountAuthError(overviewError)
? renderSignedOut()
: renderError(overviewError, loadOverview))}
{!overviewLoading && !overviewError && user && (
<>
{/* User Profile Card */}
<div className="rounded-lg border border-border p-5">
<div className="flex items-start gap-4">
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-primary/20 text-2xl font-bold text-primary">
{user.displayName?.charAt(0) ??
user.email?.charAt(0) ??
"?"}
</div>
<div className="min-w-0 flex-1">
<h3 className="text-base font-semibold text-foreground">
{user.displayName || user.email}
</h3>
<p className="mt-0.5 text-sm text-muted-foreground">
{user.email}
</p>
<p className="mt-2 text-xs text-muted-foreground">
Member since {formatDate(user.createdAt)}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<a
href="https://app.cline.bot/dashboard"
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
Open dashboard
<ExternalLink className="h-3.5 w-3.5" />
</a>
<Button
className="h-8 rounded-md px-2.5 text-xs"
disabled={accountActionPending !== null}
onClick={() => void signOut()}
type="button"
variant="outline"
>
{accountActionPending === "sign-out" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<LogOut className="h-3.5 w-3.5" />
)}
{accountActionPending === "sign-out"
? "Signing out"
: "Sign out"}
</Button>
</div>
</div>
</div>
{/* Balance Card */}
{displayedBalance !== null && (
<div className="rounded-lg border border-border p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<CreditCard className="h-5 w-5 text-primary" />
<h3 className="text-sm font-semibold text-foreground">
{activeOrganization
? `${activeOrganization.name} Balance`
: "Credits Balance"}
</h3>
</div>
<a
href="https://app.cline.bot/dashboard/organization?tab=credits&redirect=true"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<Plus className="h-3.5 w-3.5" />
Credit
</a>
</div>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold text-foreground">
${formatCreditBalance(displayedBalance)}
</span>
</div>
{activeOrganization && balance && (
<p className="mt-2 text-xs text-muted-foreground">
Personal account: {formatCreditBalance(balance.balance)}{" "}
credits
</p>
)}
</div>
)}
{/* Organizations */}
<div className="rounded-lg border border-border p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<Building className="h-5 w-5 text-muted-foreground" />
<h3 className="text-sm font-semibold text-foreground">
Organizations
</h3>
</div>
<a
href="https://app.cline.bot/onboarding?step=1"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<Plus className="h-3.5 w-3.5" />
Create
</a>
</div>
{organizations.length === 0 ? (
<p className="text-sm text-muted-foreground">
No organizations yet.
</p>
) : (
<div className="flex flex-col gap-2">
{organizations.map((org) => (
<div
key={org.organizationId}
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 transition-colors hover:bg-accent/20"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-secondary text-sm font-bold text-foreground">
{org.name.charAt(0)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">
{org.name}
</p>
<p className="text-xs text-muted-foreground capitalize">
{org.roles.join(", ")}
</p>
</div>
{org.active && (
<span className="rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary">
Active
</span>
)}
</div>
))}
</div>
)}
</div>
</>
)}
</div>
)}
{/* Usage Tab */}
{activeTab === "usage" && (
<div>
<p className="mb-6 text-sm text-muted-foreground">
{activeOrganization
? `Recent API usage and token consumption for ${activeOrganization.name}.`
: "Recent API usage and token consumption across all providers."}
</p>
{usageLoading && renderLoading()}
{usageError && renderError(usageError, loadUsage)}
{!usageLoading &&
!usageError &&
usageLoaded &&
(usageTransactions.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No usage transactions yet.
</p>
) : (
<div className="rounded-lg border border-border overflow-hidden">
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-4 border-b border-border bg-secondary/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
<span>Model</span>
<span className="text-right">Tokens</span>
<span className="text-right">Credits</span>
<span className="text-right">Time</span>
</div>
<div className="divide-y divide-border">
{usageTransactions.map((tx) => (
<div
key={tx.id}
className="grid grid-cols-[1fr_auto_auto_auto] gap-4 px-4 py-3 text-sm transition-colors hover:bg-accent/20"
>
<div className="min-w-0">
<p className="font-medium text-foreground truncate">
{tx.aiModelName}
</p>
<p className="text-xs text-muted-foreground">
{tx.aiInferenceProviderName}
</p>
</div>
<div className="text-right text-muted-foreground">
{tx.totalTokens.toLocaleString()}
</div>
<div className="text-right text-foreground font-medium">
{formatCreditBalance(tx.creditsUsed)}
</div>
<div className="text-right text-xs text-muted-foreground">
<p>{formatDate(tx.createdAt)}</p>
<p>{formatTime(tx.createdAt)}</p>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
{/* Billing Tab */}
{activeTab === "billing" && (
<div>
<p className="mb-6 text-sm text-muted-foreground">
Payment history and credit purchases.
</p>
{billingLoading && renderLoading()}
{billingError && renderError(billingError, loadBilling)}
{!billingLoading &&
!billingError &&
billingLoaded &&
(paymentTransactions.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No payment transactions yet.
</p>
) : (
<div className="rounded-lg border border-border overflow-hidden">
<div className="grid grid-cols-[1fr_auto_auto] gap-4 border-b border-border bg-secondary/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
<span>Date</span>
<span className="text-right">Amount</span>
<span className="text-right">Credits</span>
</div>
<div className="divide-y divide-border">
{paymentTransactions.map((tx) => (
<div
key={`${tx.paidAt}-${tx.amountCents}-${tx.credits}`}
className="grid grid-cols-[1fr_auto_auto] gap-4 px-4 py-3 text-sm transition-colors hover:bg-accent/20"
>
<div className="flex items-center gap-3">
<Receipt className="h-4 w-4 text-muted-foreground" />
<span className="text-foreground">
{formatDate(tx.paidAt)}
</span>
</div>
<div className="text-right text-foreground font-medium">
${(tx.amountCents / 100).toFixed(2)}
</div>
<div className="text-right text-primary font-medium">
+{formatCreditBalance(tx.credits)}
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</PageFrame>
);
}
@@ -1,529 +0,0 @@
"use client";
import {
ArrowLeft,
ChevronDown,
Copy,
Eye,
EyeOff,
Plus,
Trash2,
X,
} from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { PageFrame, PageHeader } from "../page-layout";
const CAPABILITY_OPTIONS = [
"streaming",
"tools",
"reasoning",
"vision",
"prompt-cache",
] as const;
type Capability = (typeof CAPABILITY_OPTIONS)[number];
export interface AddProviderPayload {
providerId: string;
name: string;
baseUrl: string;
apiKey?: string;
headers?: Record<string, string>;
timeoutMs?: number;
models: string[];
defaultModelId?: string;
modelsSourceUrl?: string;
capabilities?: Capability[];
}
interface NewProviderForm {
providerId: string;
name: string;
models: string[];
defaultModel: string;
apiKey: string;
baseUrl: string;
modelsSourceUrl: string;
headers: Record<string, string>;
timeoutMs: string;
capabilities: Capability[];
}
export function AddProviderContent({
onBack,
onSave,
existingProviderIds,
}: {
onBack: () => void;
onSave: (payload: AddProviderPayload) => Promise<void>;
existingProviderIds: string[];
}) {
const [form, setForm] = useState<NewProviderForm>({
providerId: "",
name: "",
models: [],
defaultModel: "",
apiKey: "",
baseUrl: "",
modelsSourceUrl: "",
headers: {},
timeoutMs: "",
capabilities: ["streaming", "tools"],
});
const [modelInput, setModelInput] = useState("");
const [showApiKey, setShowApiKey] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const normalizedProviderId = useMemo(
() => form.providerId.trim().toLowerCase().replace(/\s+/g, "-"),
[form.providerId],
);
const duplicateProviderId =
existingProviderIds.includes(normalizedProviderId);
const hasManualModels = form.models.length > 0;
const hasModelsSource = form.modelsSourceUrl.trim().length > 0;
const canSave =
normalizedProviderId.length > 0 &&
form.name.trim().length > 0 &&
form.baseUrl.trim().length > 0 &&
(hasManualModels || hasModelsSource) &&
!duplicateProviderId;
const handleAddModel = (e: React.KeyboardEvent<HTMLInputElement>) => {
if ((e.key === "Enter" || e.key === ",") && modelInput.trim()) {
e.preventDefault();
const value = modelInput.trim().replace(/,/g, "");
if (value && !form.models.includes(value)) {
setForm((prev) => ({
...prev,
models: [...prev.models, value],
defaultModel: prev.defaultModel || value,
}));
}
setModelInput("");
} else if (e.key === "Backspace" && !modelInput && form.models.length > 0) {
setForm((prev) => ({
...prev,
models: prev.models.slice(0, -1),
}));
}
};
const removeModel = (model: string) => {
setForm((prev) => {
const nextModels = prev.models.filter((m) => m !== model);
return {
...prev,
models: nextModels,
defaultModel:
prev.defaultModel === model
? (nextModels[0] ?? "")
: prev.defaultModel,
};
});
};
const toggleCapability = (cap: Capability) => {
setForm((prev) => ({
...prev,
capabilities: prev.capabilities.includes(cap)
? prev.capabilities.filter((c) => c !== cap)
: [...prev.capabilities, cap],
}));
};
const addHeader = () => {
setForm((prev) => ({ ...prev, headers: { ...prev.headers, "": "" } }));
};
const updateHeaderKey = (oldKey: string, newKey: string, idx: number) => {
const entries = Object.entries(form.headers);
const next: Record<string, string> = {};
entries.forEach(([key, value], index) => {
next[index === idx ? newKey : key] = value;
});
if (oldKey !== newKey) {
delete next[oldKey];
}
setForm((prev) => ({ ...prev, headers: next }));
};
const updateHeaderValue = (key: string, value: string) => {
setForm((prev) => ({
...prev,
headers: { ...prev.headers, [key]: value },
}));
};
const removeHeader = (key: string) => {
const next = { ...form.headers };
delete next[key];
setForm((prev) => ({ ...prev, headers: next }));
};
const handleSave = async () => {
if (!canSave || saving) {
return;
}
setSaving(true);
setError(null);
try {
await onSave({
providerId: normalizedProviderId,
name: form.name.trim(),
baseUrl: form.baseUrl.trim(),
apiKey: form.apiKey.trim() || undefined,
headers: Object.fromEntries(
Object.entries(form.headers)
.map(([key, value]) => [key.trim(), value])
.filter(([key]) => key.length > 0),
),
timeoutMs:
form.timeoutMs.trim().length > 0
? Number.parseInt(form.timeoutMs.trim(), 10)
: undefined,
models: form.models,
defaultModelId: form.defaultModel || form.models[0],
modelsSourceUrl: form.modelsSourceUrl.trim() || undefined,
capabilities:
form.capabilities.length > 0 ? form.capabilities : undefined,
});
} catch (saveError) {
setError(
saveError instanceof Error ? saveError.message : String(saveError),
);
} finally {
setSaving(false);
}
};
return (
<PageFrame contentClassName="max-w-4xl">
<PageHeader
description="Add an OpenAI-compatible provider and choose its available models."
title="Add Provider"
actions={
<Button
onClick={onBack}
variant="secondary"
className="rounded-md p-1.5 transition-colors"
aria-label="Back to providers"
>
<ArrowLeft className="h-4 w-4" />
Providers
</Button>
}
/>
<div className="flex flex-col gap-6">
<div className="rounded-lg border border-border p-5">
<h3 className="mb-4 text-sm font-semibold text-foreground">
OpenAI-Compatible Provider
</h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Provider ID
</Label>
<input
type="text"
value={form.providerId}
onChange={(e) =>
setForm((prev) => ({ ...prev, providerId: e.target.value }))
}
placeholder="my-provider"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<p className="mt-1.5 text-xs text-muted-foreground">
Lowercase ID used in provider registry.
</p>
{duplicateProviderId ? (
<p className="mt-1 text-xs text-destructive">
This provider ID already exists.
</p>
) : null}
</div>
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Provider Name
</Label>
<input
type="text"
value={form.name}
onChange={(e) =>
setForm((prev) => ({ ...prev, name: e.target.value }))
}
placeholder="My Provider"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
</div>
</div>
</div>
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Base URL
</Label>
<input
type="url"
value={form.baseUrl}
onChange={(e) =>
setForm((prev) => ({ ...prev, baseUrl: e.target.value }))
}
placeholder="https://api.example.com/v1"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
</div>
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Model Source URL (Optional)
</Label>
<input
type="url"
value={form.modelsSourceUrl}
onChange={(e) =>
setForm((prev) => ({
...prev,
modelsSourceUrl: e.target.value,
}))
}
placeholder="https://api.example.com/v1/models"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<p className="mt-1.5 text-xs text-muted-foreground">
Supported JSON: OpenAI `/models` shape with a `data` array, or a
direct model array.
</p>
</div>
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Models
</Label>
<div className="flex min-h-11 flex-wrap content-start gap-1.5 rounded-lg border border-border bg-input px-3 py-2 focus-within:ring-1 focus-within:ring-ring">
{form.models.map((model) => (
<span
key={model}
className="inline-flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-xs font-medium text-primary"
>
<span className="font-mono">{model}</span>
<Button
onClick={() => removeModel(model)}
className="text-primary/60 hover:text-primary transition-colors"
aria-label={`Remove ${model}`}
>
<X className="h-3 w-3" />
</Button>
</span>
))}
<input
type="text"
value={modelInput}
onChange={(e) => setModelInput(e.target.value)}
onKeyDown={handleAddModel}
placeholder={
form.models.length === 0 ? "Type model ID and press Enter" : ""
}
className="min-w-35 flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground/50 outline-none"
/>
</div>
<p className="mt-1.5 text-xs text-muted-foreground">
Add at least one model or set a Model Source URL.
</p>
</div>
{form.models.length > 1 ? (
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Default Model
</Label>
<select
value={form.defaultModel}
onChange={(e) =>
setForm((prev) => ({ ...prev, defaultModel: e.target.value }))
}
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
>
{form.models.map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</select>
</div>
) : null}
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
API Key (Optional)
</Label>
<div className="relative">
<input
type={showApiKey ? "text" : "password"}
value={form.apiKey}
onChange={(e) =>
setForm((prev) => ({ ...prev, apiKey: e.target.value }))
}
placeholder="sk-..."
className="w-full rounded-lg border border-border bg-input px-3 py-2 pr-20 font-mono text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
<Button
onClick={() => setShowApiKey(!showApiKey)}
variant="ghost"
className="rounded-md p-1 transition-colors"
aria-label={showApiKey ? "Hide API key" : "Show API key"}
>
{showApiKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
<Button
onClick={() => navigator.clipboard.writeText(form.apiKey)}
variant="ghost"
className="rounded-md p-1 transition-colors"
aria-label="Copy API key"
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<div className="rounded-lg border border-border p-5">
<Label className="mb-3 block text-xs font-medium text-muted-foreground">
Capabilities
</Label>
<div className="flex flex-wrap gap-2">
{CAPABILITY_OPTIONS.map((cap) => (
<Button
key={cap}
onClick={() => toggleCapability(cap)}
className={cn(
"rounded-lg border px-3 py-1.5 text-xs font-medium transition-all",
form.capabilities.includes(cap)
? "border-primary/40 bg-primary/10 text-primary"
: "border-border bg-card text-muted-foreground hover:border-muted-foreground/50 hover:text-foreground",
)}
>
{cap.replace(/-/g, " ")}
</Button>
))}
</div>
</div>
<div className="rounded-lg border border-border overflow-hidden">
<Button
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex w-full items-center justify-between px-5 py-4 text-sm font-medium transition-colors text-foreground/40"
variant="ghost"
>
Advanced Settings
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform",
showAdvanced && "rotate-180",
)}
/>
</Button>
{showAdvanced ? (
<div className="border-t border-border px-5 py-5 flex flex-col gap-5">
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Timeout (ms)
</Label>
<input
type="number"
value={form.timeoutMs}
onChange={(e) =>
setForm((prev) => ({
...prev,
timeoutMs: e.target.value,
}))
}
placeholder="30000"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
</div>
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Custom Headers
</Label>
<div className="flex flex-col gap-2">
{Object.entries(form.headers).map(([key, value], idx) => (
<div key={key} className="flex items-center gap-2">
<input
type="text"
value={key}
onChange={(e) =>
updateHeaderKey(key, e.target.value, idx)
}
placeholder="Header name"
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<input
type="text"
value={value}
onChange={(e) => updateHeaderValue(key, e.target.value)}
placeholder="Value"
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<Button
onClick={() => removeHeader(key)}
className="rounded-md p-2 text-muted-foreground hover:text-destructive transition-colors"
aria-label="Remove header"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
onClick={addHeader}
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors w-fit"
>
<Plus className="h-3 w-3" />
Add Header
</Button>
</div>
</div>
</div>
) : null}
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<div className="flex items-center justify-end gap-3 pt-2">
<Button
onClick={onBack}
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
Cancel
</Button>
<Button
onClick={() => void handleSave()}
disabled={!canSave || saving}
className={cn(
"rounded-lg px-4 py-2 text-sm font-medium transition-colors",
canSave && !saving
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-muted text-muted-foreground cursor-not-allowed",
)}
>
{saving ? "Saving..." : "Add Provider"}
</Button>
</div>
</div>
</PageFrame>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,71 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { WebviewInboundMessage } from "../../../webview-protocol";
import { HubDesktopClient, isBrowserTransportFailure } from "./desktop-client";
function createClient() {
const postToHost = vi.fn<(message: WebviewInboundMessage) => void>();
const client = new HubDesktopClient({ postToHost, listen: false });
return { client, postToHost };
}
function lastDesktopCommand(postToHost: ReturnType<typeof vi.fn>) {
const message = postToHost.mock.lastCall?.[0] as
| Extract<WebviewInboundMessage, { type: "desktopCommand" }>
| undefined;
if (message?.type !== "desktopCommand") {
throw new Error("Expected a desktop command to be posted");
}
return message;
}
describe("HubDesktopClient", () => {
it("does not reject pending desktop commands for unrelated hub errors", async () => {
const { client, postToHost } = createClient();
const pending = client.invoke<{ installedKeys: string[] }>(
"list_marketplace_installed_entries",
);
const command = lastDesktopCommand(postToHost);
client.handleMessage({
data: { type: "error", text: "Failed to restore previous session." },
});
client.handleMessage({
data: {
type: "desktopCommandResult",
id: command.id,
ok: true,
result: { installedKeys: ["plugin:goal"] },
},
});
await expect(pending).resolves.toEqual({ installedKeys: ["plugin:goal"] });
});
it("rejects pending desktop commands for browser transport failures", async () => {
const { client } = createClient();
const pending = client.invoke("list_marketplace_installed_entries");
client.handleMessage({
data: { type: "status", text: "Disconnected from the Cline Hub server." },
});
await expect(pending).rejects.toThrow(
"Disconnected from the Cline Hub server.",
);
});
it("only treats exact browser lifecycle messages as transport failures", () => {
expect(
isBrowserTransportFailure({
type: "error",
text: "Failed to connect to the Cline Hub server.",
}),
).toBe(true);
expect(
isBrowserTransportFailure({
type: "error",
text: "Failed to restore previous session.",
}),
).toBe(false);
});
});
@@ -1,105 +0,0 @@
"use client";
import type { WebviewOutboundMessage } from "../../../webview-protocol";
import { postToHost } from "../vscode";
type PostToHost = typeof postToHost;
type PendingRequest = {
command: string;
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timeoutId: ReturnType<typeof setTimeout>;
};
const REQUEST_TIMEOUT_MS = 120_000;
const BROWSER_TRANSPORT_FAILURE_MESSAGES = new Set([
"Disconnected from the Cline Hub server.",
"Failed to connect to the Cline Hub server.",
"Received an invalid message from the Cline Hub server.",
]);
export function isBrowserTransportFailure(
message: WebviewOutboundMessage,
): boolean {
if (message.type !== "status" && message.type !== "error") {
return false;
}
return BROWSER_TRANSPORT_FAILURE_MESSAGES.has(message.text);
}
export class HubDesktopClient {
private requestCounter = 0;
private readonly pending = new Map<string, PendingRequest>();
private readonly postToHost: PostToHost;
constructor(options: { postToHost?: PostToHost; listen?: boolean } = {}) {
this.postToHost = options.postToHost ?? postToHost;
if ((options.listen ?? true) && typeof window !== "undefined") {
window.addEventListener("message", (event) => {
this.handleMessage(event as MessageEvent<WebviewOutboundMessage>);
});
}
}
handleMessage(event: Pick<MessageEvent<WebviewOutboundMessage>, "data">) {
const message = event.data;
if (
message &&
typeof message === "object" &&
(message.type === "status" || message.type === "error")
) {
if (isBrowserTransportFailure(message) && this.pending.size > 0) {
const error = new Error(message.text);
for (const pending of this.pending.values()) {
clearTimeout(pending.timeoutId);
pending.reject(error);
}
this.pending.clear();
}
return;
}
if (
!message ||
typeof message !== "object" ||
message.type !== "desktopCommandResult"
) {
return;
}
const pending = this.pending.get(message.id);
if (!pending) {
return;
}
clearTimeout(pending.timeoutId);
this.pending.delete(message.id);
if (message.ok) {
pending.resolve(message.result);
return;
}
pending.reject(new Error(message.error));
}
async invoke<T>(
command: string,
args?: Record<string, unknown>,
options?: { timeoutMs?: number },
): Promise<T> {
const id = `desktop_${Date.now()}_${this.requestCounter++}`;
return await new Promise<T>((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`Timed out waiting for desktop command: ${command}`));
}, options?.timeoutMs ?? REQUEST_TIMEOUT_MS);
this.pending.set(id, {
command,
resolve: (value) => resolve(value as T),
reject,
timeoutId,
});
this.postToHost({ type: "desktopCommand", id, command, args });
});
}
}
export const desktopClient = new HubDesktopClient();
@@ -1,194 +0,0 @@
export type MarketplacePrimitiveType = "mcp" | "skill" | "plugin";
export type MarketplaceTag = {
id: string;
label: string;
count: number;
};
export type MarketplaceEnvVar = {
name: string;
required?: boolean;
description?: string;
url?: string;
};
export type MarketplaceEntry = {
id: string;
type: MarketplacePrimitiveType;
name: string;
featured?: boolean;
tagline: string;
description: string;
tags: string[];
install: {
args: string[];
env?: MarketplaceEnvVar[];
notes?: string;
command: string;
};
};
export type MarketplaceCatalog = {
version: number;
generatedAt?: string;
baseUrl?: string;
counts: {
total: number;
plugins: number;
skills: number;
mcps: number;
};
tags: MarketplaceTag[];
entries: MarketplaceEntry[];
};
const MARKETPLACE_CATALOG_URL = "/api/marketplace/catalog";
const EMPTY_CATALOG: MarketplaceCatalog = {
version: 1,
counts: {
total: 0,
plugins: 0,
skills: 0,
mcps: 0,
},
tags: [],
entries: [],
};
function isPrimitiveType(value: unknown): value is MarketplacePrimitiveType {
return value === "mcp" || value === "skill" || value === "plugin";
}
function toStringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: [];
}
function parseCount(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function parseEnv(value: unknown): MarketplaceEnvVar[] | undefined {
if (!Array.isArray(value)) return undefined;
const env = value
.map((item): MarketplaceEnvVar | null => {
if (!item || typeof item !== "object") return null;
const candidate = item as Record<string, unknown>;
if (typeof candidate.name !== "string") return null;
const parsed: MarketplaceEnvVar = {
name: candidate.name,
};
if (typeof candidate.required === "boolean") {
parsed.required = candidate.required;
}
if (typeof candidate.description === "string") {
parsed.description = candidate.description;
}
if (typeof candidate.url === "string") {
parsed.url = candidate.url;
}
return parsed;
})
.filter((item): item is MarketplaceEnvVar => item !== null);
return env.length > 0 ? env : undefined;
}
export async function fetchMarketplaceCatalog(): Promise<MarketplaceCatalog> {
const response = await fetch(MARKETPLACE_CATALOG_URL, {
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(`Failed to fetch marketplace: ${response.status}`);
}
const data = await response.json();
const baseUrl = typeof data?.baseUrl === "string" ? data.baseUrl : undefined;
const rawCounts =
typeof data?.counts === "object" && data.counts !== null ? data.counts : {};
const tags: MarketplaceTag[] = Array.isArray(data?.tags)
? data.tags
.map((tag: unknown) => {
if (!tag || typeof tag !== "object") return null;
const candidate = tag as Record<string, unknown>;
if (
typeof candidate.id !== "string" ||
typeof candidate.label !== "string"
) {
return null;
}
return {
id: candidate.id,
label: candidate.label,
count: parseCount(candidate.count),
};
})
.filter(
(tag: MarketplaceTag | null): tag is MarketplaceTag => tag !== null,
)
: [];
const entries: MarketplaceEntry[] = Array.isArray(data?.entries)
? data.entries
.map((entry: unknown) => {
if (!entry || typeof entry !== "object") return null;
const candidate = entry as Record<string, unknown>;
const install =
typeof candidate.install === "object" && candidate.install !== null
? (candidate.install as Record<string, unknown>)
: {};
if (
typeof candidate.id !== "string" ||
!isPrimitiveType(candidate.type) ||
typeof candidate.name !== "string" ||
typeof candidate.tagline !== "string" ||
typeof candidate.description !== "string" ||
typeof install.command !== "string"
) {
return null;
}
return {
id: candidate.id,
type: candidate.type,
name: candidate.name,
featured:
typeof candidate.featured === "boolean"
? candidate.featured
: undefined,
tagline: candidate.tagline,
description: candidate.description,
tags: toStringArray(candidate.tags),
install: {
args: toStringArray(install.args),
command: install.command,
env: parseEnv(install.env),
notes:
typeof install.notes === "string" ? install.notes : undefined,
},
};
})
.filter(
(entry: MarketplaceEntry | null): entry is MarketplaceEntry =>
entry !== null && entry.install.args.length > 0,
)
: [];
return {
version: parseCount(data?.version) || EMPTY_CATALOG.version,
generatedAt:
typeof data?.generatedAt === "string" ? data.generatedAt : undefined,
baseUrl,
counts: {
total: parseCount(rawCounts.total) || entries.length,
plugins: parseCount(rawCounts.plugins),
skills: parseCount(rawCounts.skills),
mcps: parseCount(rawCounts.mcps),
},
tags,
entries,
};
}
export { EMPTY_CATALOG, MARKETPLACE_CATALOG_URL };
@@ -1,30 +0,0 @@
export const HUB_THEME_STORAGE_KEY = "cline-hub-theme";
export type HubTheme = "light" | "dark";
export function readStoredHubTheme(): HubTheme | null {
const stored = window.localStorage.getItem(HUB_THEME_STORAGE_KEY);
return stored === "light" || stored === "dark" ? stored : null;
}
export function readSystemHubTheme(): HubTheme {
const kind = document.body.dataset.vscodeThemeKind;
return kind === "vscode-dark" || kind === "vscode-high-contrast"
? "dark"
: "light";
}
export function applyHubTheme(theme: HubTheme): HubTheme {
document.documentElement.classList.toggle("dark", theme === "dark");
document.documentElement.dataset.clineHubTheme = theme;
return theme;
}
export function syncHubTheme(): HubTheme {
return applyHubTheme(readStoredHubTheme() ?? readSystemHubTheme());
}
export function setStoredHubTheme(theme: HubTheme): HubTheme {
window.localStorage.setItem(HUB_THEME_STORAGE_KEY, theme);
return applyHubTheme(theme);
}
-58
View File
@@ -1,58 +0,0 @@
import path from "node:path";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react-swc";
import { defineConfig } from "vite";
const mermaidChunkGroups = [
{
name: "mermaid-parser",
maxSize: 450_000,
test: /node_modules[\\/](?:\.bun[\\/])?@mermaid-js[+]parser/,
},
{
name: "mermaid-langium",
test: /node_modules[\\/](?:\.bun[\\/])?langium/,
},
{
name: "mermaid-layout",
maxSize: 450_000,
test: /node_modules[\\/](?:\.bun[\\/])?(?:cytoscape|cytoscape-cose-bilkent|dagre|elkjs)/,
},
{
name: "mermaid-markup",
test: /node_modules[\\/](?:\.bun[\\/])?(?:katex|dompurify)/,
},
];
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
dedupe: ["react", "react-dom"],
},
base: "./",
server: {
cors: true,
headers: {
"Access-Control-Allow-Origin": "*",
},
hmr: {
host: "localhost",
},
},
build: {
outDir: "../../dist/webview",
emptyOutDir: true,
cssMinify: "esbuild",
chunkSizeWarningLimit: 600,
rolldownOptions: {
output: {
codeSplitting: {
groups: mermaidChunkGroups,
},
},
},
},
});
-21
View File
@@ -1,21 +0,0 @@
{
"extends": "../../sdk/packages/tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"types": ["bun", "node"],
"paths": {
"@cline/core": ["../../sdk/packages/core/src/index.ts"],
"@cline/core/*": [
"../../sdk/packages/core/src/*",
"../../sdk/packages/core/src/*/index.ts"
],
"@cline/shared": ["../../sdk/packages/shared/src/index.ts"],
"@cline/shared/*": [
"../../sdk/packages/shared/src/*",
"../../sdk/packages/shared/src/*/index.ts"
]
}
},
"include": ["src/**/*.ts"],
"exclude": ["src/webview/**"]
}
-33
View File
@@ -1,33 +0,0 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
const rootDir = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
root: rootDir,
resolve: {
alias: [
{
find: /^@cline\/core$/,
replacement: resolve(rootDir, "../../sdk/packages/core/src/index.ts"),
},
{
find: /^@cline\/core\/(.+)$/,
replacement: resolve(rootDir, "../../sdk/packages/core/src/$1"),
},
{
find: /^@cline\/shared$/,
replacement: resolve(rootDir, "../../sdk/packages/shared/src/index.ts"),
},
{
find: /^@cline\/shared\/(.+)$/,
replacement: resolve(rootDir, "../../sdk/packages/shared/src/$1"),
},
],
},
test: {
environment: "node",
include: ["src/**/*.test.ts"],
},
});
@@ -1,23 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"paths": {
"@/*": ["./*"],
"@cline/agents": ["../../../sdk/packages/agents/src/index.ts"],
"@cline/core": ["../../../sdk/packages/core/src/index.ts"],
"@cline/core/hub": ["../../../sdk/packages/core/src/hub/index.ts"],
"@cline/llms": ["../../../sdk/packages/llms/src/index.ts"],
"@cline/shared": ["../../../sdk/packages/shared/src/index.ts"],
"@cline/shared/storage": [
"../../../sdk/packages/shared/src/storage/index.ts"
],
"@cline/shared/db": ["../../../sdk/packages/shared/src/db/index.ts"],
"@cline/shared/*": [
"../../../sdk/packages/shared/src/*",
"../../../sdk/packages/shared/src/*/index.ts"
]
}
},
"include": ["sidecar/**/*.ts", "scripts/**/*.ts", "global.d.ts", "bun.mts"],
"exclude": ["node_modules", "webview"]
}
@@ -1,28 +0,0 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"paths": {
"@/*": ["./*"],
"@cline/agents": ["../../../../sdk/packages/agents/src/index.ts"],
"@cline/core": ["../../../../sdk/packages/core/src/index.ts"],
"@cline/llms": ["../../../../sdk/packages/llms/src/index.ts"],
"@cline/shared": ["../../../../sdk/packages/shared/src/index.ts"],
"@cline/shared/storage": [
"../../../../sdk/packages/shared/src/storage/index.ts"
],
"@cline/shared/db": ["../../../../sdk/packages/shared/src/db/index.ts"],
"@cline/shared/*": [
"../../../../sdk/packages/shared/src/*",
"../../../../sdk/packages/shared/src/*/index.ts"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}
-35
View File
@@ -1,35 +0,0 @@
{
"extends": "../../../sdk/packages/tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ESNext",
"paths": {
"@cline/agents": ["../../../sdk/packages/agents/src/index.ts"],
"@cline/agents/*": [
"../../../sdk/packages/agents/src/*",
"../../../sdk/packages/agents/src/*/index.ts"
],
"@cline/core": ["../../../sdk/packages/core/src/index.ts"],
"@cline/core/hub/daemon-entry": [
"../../../sdk/packages/core/src/hub/daemon/entry.ts"
],
"@cline/core/*": [
"../../../sdk/packages/core/src/*",
"../../../sdk/packages/core/src/*/index.ts"
],
"@cline/llms": ["../../../sdk/packages/llms/src/index.ts"],
"@cline/shared": ["../../../sdk/packages/shared/src/index.ts"],
"@cline/shared/storage": [
"../../../sdk/packages/shared/src/storage/index.ts"
],
"@cline/shared/db": ["../../../sdk/packages/shared/src/db/index.ts"],
"@cline/shared/*": [
"../../../sdk/packages/shared/src/*",
"../../../sdk/packages/shared/src/*/index.ts"
]
}
},
"include": ["sidecar/**/*.ts"]
}
-31
View File
@@ -1,31 +0,0 @@
{
"compilerOptions": {
"paths": {
"@cline/agents": ["../sdk/packages/agents/src/index.ts"],
"@cline/agents/*": [
"../sdk/packages/agents/src/*",
"../sdk/packages/agents/src/*/index.ts"
],
"@cline/cline-hub": ["./cline-hub/src/server.ts"],
"@cline/core": ["../sdk/packages/core/src/index.ts"],
"@cline/core/hub/daemon-entry": [
"../sdk/packages/core/src/hub/daemon/entry.ts"
],
"@cline/core/*": [
"../sdk/packages/core/src/*",
"../sdk/packages/core/src/*/index.ts"
],
"@cline/core/telemetry": [
"../sdk/packages/core/src/services/telemetry/index.ts"
],
"@cline/llms": ["../sdk/packages/llms/src/index.ts"],
"@cline/shared": ["../sdk/packages/shared/src/index.ts"],
"@cline/shared/storage": ["../sdk/packages/shared/src/storage/index.ts"],
"@cline/shared/db": ["../sdk/packages/shared/src/db/index.ts"],
"@cline/shared/*": [
"../sdk/packages/shared/src/*",
"../sdk/packages/shared/src/*/index.ts"
]
}
}
}
-201
View File
@@ -1,201 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Cline Bot Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+4
View File
@@ -1,3 +1,7 @@
<div align="center"><sub>
English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md" target="_blank">Español</a> | <a href="https://github.com/cline/cline/blob/main/locales/de/README.md" target="_blank">Deutsch</a> | <a href="https://github.com/cline/cline/blob/main/locales/ja/README.md" target="_blank">日本語</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-cn/README.md" target="_blank">简体中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-tw/README.md" target="_blank">繁體中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/ko/README.md" target="_blank">한국어</a>
</sub></div>
# Cline
<div align="center">
<table>
+14 -7
View File
@@ -50,8 +50,8 @@
"useEnumInitializers": "off",
"useSelfClosingElements": "info",
"useSingleVarDeclarator": "off",
"useNumberNamespace": "off",
"noInferrableTypes": "off",
"useNumberNamespace": "info",
"noInferrableTypes": "info",
"useTemplate": "info",
"noUselessElse": "info"
},
@@ -122,7 +122,6 @@
"!!**/out",
"!!**/evals",
"!!**/playwright",
"!!**/.vscode-test",
"!!**/test-results",
"!!**/node_modules",
"!!**/webview-ui/build",
@@ -131,7 +130,9 @@
"!!**/tests/specs"
]
},
"plugins": ["src/dev/grit/process-env.grit"],
"plugins": [
"src/dev/grit/process-env.grit"
],
"overrides": [
{
"includes": [
@@ -146,11 +147,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 +188,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"
]
}
]
}
-3
View File
@@ -126,9 +126,6 @@ const copyWasmFiles = {
const buildEnvVars = {
"import.meta.url": "_importMetaUrl",
"process.env.IS_STANDALONE": JSON.stringify(standalone ? "true" : "false"),
// Always inline these values so ordinary builds cannot be mislabeled by a
// user's runtime environment. Only the combined rollout workflow sets them.
"process.env.CLINE_ROLLOUT_VARIANT": JSON.stringify(process.env.CLINE_ROLLOUT_VARIANT || ""),
}
if (production) {
+48 -23
View File
@@ -1,16 +1,19 @@
{
"name": "claude-dev",
"version": "3.89.2",
"version": "3.86.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
"version": "3.89.2",
"version": "3.86.2",
"license": "Apache-2.0",
"workspaces": [
"."
],
"dependencies": {
"@anthropic-ai/sdk": "^0.50.4",
"@anthropic-ai/vertex-sdk": "^0.11.5",
"@anthropic-ai/sdk": "^0.37.0",
"@anthropic-ai/vertex-sdk": "^0.6.4",
"@aws-sdk/client-bedrock-runtime": "^3.922.0",
"@aws-sdk/credential-providers": "^3.922.0",
"@azure/identity": "^4.13.0",
@@ -156,24 +159,42 @@
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.50.4",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.50.4.tgz",
"integrity": "sha512-zZOWyIuznx2uqiRcCNuidkAsLC8IBHgS9lTwSVEB29sUCwEcqL95MWpWP1nccHSKfiZga+hbZaI0btR2zjuMmw==",
"license": "MIT",
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/@anthropic-ai/vertex-sdk": {
"version": "0.11.5",
"resolved": "https://registry.npmjs.org/@anthropic-ai/vertex-sdk/-/vertex-sdk-0.11.5.tgz",
"integrity": "sha512-V7sB5nY80unEQu8lSQaEzh1WhYwpIdpC3iXNRHUskghkuQDhS6dQu2ASZBgA5MNuJ1Yv5PhY61NM15dLaQhPQw==",
"version": "0.37.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.37.0.tgz",
"integrity": "sha512-tHjX2YbkUBwEgg0JZU3EFSSAQPoK4qQR/NFYa8Vtzd5UAyXzZksCw2In69Rml4R/TyHPBfRYaLK35XiOe33pjw==",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": ">=0.50.3 <1",
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.4",
"abort-controller": "^3.0.0",
"agentkeepalive": "^4.2.1",
"form-data-encoder": "1.7.2",
"formdata-node": "^4.3.2",
"node-fetch": "^2.6.7"
}
},
"node_modules/@anthropic-ai/sdk/node_modules/@types/node": {
"version": "18.19.130",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/@anthropic-ai/sdk/node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"license": "MIT"
},
"node_modules/@anthropic-ai/vertex-sdk": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/@anthropic-ai/vertex-sdk/-/vertex-sdk-0.6.4.tgz",
"integrity": "sha512-rMBlO2jF53TfMRmsQMm1bPO2JRUh4jYddjq/OJLj8DSAkfbCrNWhc0yhDed6oLYJg5s+VpDbvlPzMggqHhTfMw==",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": ">=0.35 <1",
"google-auth-library": "^9.4.2"
}
},
@@ -9361,6 +9382,10 @@
"integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==",
"license": "MIT"
},
"node_modules/claude-dev": {
"resolved": "",
"link": true
},
"node_modules/clean-stack": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
@@ -18525,9 +18550,9 @@
}
},
"node_modules/shell-quote": {
"version": "1.8.4",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
+12 -9
View File
@@ -2,8 +2,11 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "4.0.9",
"version": "3.86.2",
"icon": "assets/icons/icon.png",
"workspaces": [
"."
],
"engines": {
"vscode": "^1.84.0"
},
@@ -389,18 +392,18 @@
"protos": "node scripts/build-proto.mjs",
"protos-python": "node scripts/build-python-proto.mjs",
"download-ripgrep": "node scripts/download-ripgrep.mjs",
"postprotos": "biome format --config-path ./biome.jsonc src/shared/proto src/generated webview-ui/src/services/grpc-client.ts --write --no-errors-on-unmatched",
"postprotos": "biome format src/shared/proto src/core/controller src/hosts/ webview-ui/src/services src/generated --write --no-errors-on-unmatched",
"clean:build": "rimraf dist dist-standalone webview-ui/build src/generated out/",
"clean:deps": "rimraf node_modules webview-ui/node_modules",
"clean:all": "npm run clean:build && npm run clean:deps",
"compile-tests": "node ./scripts/build-tests.js",
"watch-tests": "tsc -p . -w --outDir out",
"check-types": "npm run protos && tsc --noEmit && cd webview-ui && tsc --noEmit",
"lint": "biome lint --config-path ./biome.jsonc --no-errors-on-unmatched --files-ignore-unknown=true --diagnostic-level=error && npm run lint:proto",
"check-types": "npm run protos && npx tsc --noEmit && cd webview-ui && npx tsc --noEmit",
"lint": "biome lint --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",
"format": "biome format --changed --no-errors-on-unmatched --files-ignore-unknown=true --diagnostic-level=error",
"format:fix": "biome check --changed --no-errors-on-unmatched --files-ignore-unknown=true --write",
"fix:all": "biome check --no-errors-on-unmatched --files-ignore-unknown=true --write --diagnostic-level=error --unsafe",
"ci:check-all": "npx npm-run-all -p check-types lint format",
"ci:build": "npm run protos && npm run build:webview && node esbuild.mjs && npm run compile-tests",
"pretest": "npm run compile && npm run compile-tests && npm run compile-standalone && npm run lint",
@@ -486,8 +489,8 @@
"typescript": "^5.4.5"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.50.4",
"@anthropic-ai/vertex-sdk": "^0.11.5",
"@anthropic-ai/sdk": "^0.37.0",
"@anthropic-ai/vertex-sdk": "^0.6.4",
"@aws-sdk/client-bedrock-runtime": "^3.922.0",
"@aws-sdk/credential-providers": "^3.922.0",
"@azure/identity": "^4.13.0",
-18
View File
@@ -127,7 +127,6 @@ message ClineRecommendedModel {
message ClineRecommendedModelsResponse {
repeated ClineRecommendedModel recommended = 1;
repeated ClineRecommendedModel free = 2;
repeated ClineRecommendedModel cline_pass = 3;
}
// Request for fetching OpenAI models
@@ -288,8 +287,6 @@ message ModelsApiOptions {
optional OpenAiCompatibleModelInfo plan_mode_aihubmix_model_info = 134;
optional string plan_mode_cline_model_id = 135;
optional OpenRouterModelInfo plan_mode_cline_model_info = 136;
optional string plan_mode_cline_pass_model_id = 137;
optional OpenRouterModelInfo plan_mode_cline_pass_model_info = 138;
// Act mode configurations
optional ApiProvider act_mode_api_provider = 200;
@@ -329,12 +326,6 @@ message ModelsApiOptions {
optional OpenAiCompatibleModelInfo act_mode_aihubmix_model_info = 234;
optional string act_mode_cline_model_id = 235;
optional OpenRouterModelInfo act_mode_cline_model_info = 236;
optional string act_mode_cline_pass_model_id = 237;
optional OpenRouterModelInfo act_mode_cline_pass_model_info = 238;
optional bool plan_mode_vertex_custom_model_selected = 239;
optional OpenRouterModelInfo plan_mode_vertex_custom_model_info = 240;
optional bool act_mode_vertex_custom_model_selected = 241;
optional OpenRouterModelInfo act_mode_vertex_custom_model_info = 242;
}
// Request for updating API configuration (legacy - uses combined configuration)
@@ -470,7 +461,6 @@ enum ApiProvider {
NOUSRESEARCH = 39;
OPENAI_CODEX = 40;
WANDB = 41;
CLINE_PASS = 42;
}
enum ApiFormat {
@@ -654,8 +644,6 @@ message ModelsApiConfiguration {
optional string gemini_plan_mode_thinking_level = 139;
optional string plan_mode_cline_model_id = 140;
optional OpenRouterModelInfo plan_mode_cline_model_info = 141;
optional string plan_mode_cline_pass_model_id = 142;
optional OpenRouterModelInfo plan_mode_cline_pass_model_info = 143;
// Act mode configurations
optional ApiProvider act_mode_api_provider = 200;
@@ -700,10 +688,4 @@ message ModelsApiConfiguration {
optional string gemini_act_mode_thinking_level = 239;
optional string act_mode_cline_model_id = 240;
optional OpenRouterModelInfo act_mode_cline_model_info = 241;
optional string act_mode_cline_pass_model_id = 242;
optional OpenRouterModelInfo act_mode_cline_pass_model_info = 243;
optional bool plan_mode_vertex_custom_model_selected = 244;
optional OpenRouterModelInfo plan_mode_vertex_custom_model_info = 245;
optional bool act_mode_vertex_custom_model_selected = 246;
optional OpenRouterModelInfo act_mode_vertex_custom_model_info = 247;
}
-4
View File
@@ -287,10 +287,6 @@ message Settings {
optional OpenRouterModelInfo act_mode_cline_model_info = 181;
optional bool show_feature_tips = 182;
optional bool lazy_teammate_mode_enabled = 183;
optional bool plan_mode_vertex_custom_model_selected = 184;
optional OpenRouterModelInfo plan_mode_vertex_custom_model_info = 185;
optional bool act_mode_vertex_custom_model_selected = 186;
optional OpenRouterModelInfo act_mode_vertex_custom_model_info = 187;
}
message State {
@@ -1,104 +0,0 @@
import "should"
import {
type ApiConfiguration,
clinePassDefaultModelId,
clinePassModelInfoSaneDefaults,
clinePassModels,
type ModelInfo,
} from "@shared/api"
import type { Mode } from "@shared/storage/types"
import sinon from "sinon"
import { ClineAccountService } from "@/services/account/ClineAccountService"
import { AuthService } from "@/services/auth/AuthService"
import { buildApiHandler } from "../index"
describe("buildApiHandler", () => {
beforeEach(() => {
sinon.stub(ClineAccountService, "getInstance").returns({} as any)
sinon.stub(AuthService, "getInstance").returns({} as any)
})
afterEach(() => {
sinon.restore()
})
const buildClinePassHandler = (configuration: Partial<ApiConfiguration>, mode: Mode = "act") =>
buildApiHandler(
{
planModeApiProvider: "cline-pass",
actModeApiProvider: "cline-pass",
...configuration,
} as ApiConfiguration,
mode,
)
describe("cline-pass provider", () => {
const freeModelInfo: ModelInfo = {
...clinePassModelInfoSaneDefaults,
maxTokens: 32_768,
contextWindow: 256_000,
description: "A free model",
}
it("passes a free (non cline-pass prefixed) model id through with its stored info", () => {
const handler = buildClinePassHandler({
actModeClinePassModelId: "kwaipilot/kat-coder-pro",
actModeClinePassModelInfo: freeModelInfo,
})
const model = handler.getModel()
model.id.should.equal("kwaipilot/kat-coder-pro")
model.info.should.deepEqual(freeModelInfo)
})
it("passes a :free suffixed model id through with its stored info", () => {
const handler = buildClinePassHandler({
actModeClinePassModelId: "arcee-ai/trinity-large-preview:free",
actModeClinePassModelInfo: freeModelInfo,
})
const model = handler.getModel()
model.id.should.equal("arcee-ai/trinity-large-preview:free")
model.info.should.deepEqual(freeModelInfo)
})
it("falls back to sane defaults for a free model id without stored info", () => {
const handler = buildClinePassHandler({
actModeClinePassModelId: "kwaipilot/kat-coder-pro",
})
const model = handler.getModel()
model.id.should.equal("kwaipilot/kat-coder-pro")
model.info.should.deepEqual(clinePassModelInfoSaneDefaults)
})
it("resolves cline-pass ids against the static model table", () => {
const handler = buildClinePassHandler({
actModeClinePassModelId: "cline-pass/glm-5.2",
})
const model = handler.getModel()
model.id.should.equal("cline-pass/glm-5.2")
model.info.should.deepEqual(clinePassModels["cline-pass/glm-5.2"])
})
it("falls back to the default pass model when no model id is configured", () => {
const handler = buildClinePassHandler({})
const model = handler.getModel()
model.id.should.equal(clinePassDefaultModelId)
model.info.should.deepEqual(clinePassModels[clinePassDefaultModelId])
})
it("resolves plan and act mode model ids independently", () => {
const configuration: Partial<ApiConfiguration> = {
planModeClinePassModelId: "kwaipilot/kat-coder-pro",
planModeClinePassModelInfo: freeModelInfo,
actModeClinePassModelId: "cline-pass/glm-5.2",
}
buildClinePassHandler(configuration, "plan").getModel().id.should.equal("kwaipilot/kat-coder-pro")
buildClinePassHandler(configuration, "act").getModel().id.should.equal("cline-pass/glm-5.2")
})
})
})
+4 -43
View File
@@ -1,11 +1,4 @@
import {
ApiConfiguration,
buildModelInfoNameMap,
clinePassDefaultModelId,
ModelInfo,
QwenApiRegions,
resolveClinePassModelInfo,
} from "@shared/api"
import { ApiConfiguration, ModelInfo, QwenApiRegions } from "@shared/api"
import { Mode } from "@shared/storage/types"
import { ClineStorageMessage } from "@/shared/messages/content"
import { Logger } from "@/shared/services/Logger"
@@ -138,8 +131,6 @@ function createHandlerForProvider(
vertexProjectId: options.vertexProjectId,
vertexRegion: options.vertexRegion,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
vertexCustomModelInfo:
mode === "plan" ? options.planModeVertexCustomModelInfo : options.actModeVertexCustomModelInfo,
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
geminiApiKey: options.geminiApiKey,
@@ -208,7 +199,6 @@ function createHandlerForProvider(
onRetryAttempt: options.onRetryAttempt,
deepSeekApiKey: options.deepSeekApiKey,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
})
case "requesty":
return new RequestyHandler({
@@ -268,12 +258,11 @@ function createHandlerForProvider(
mode === "plan" ? options.planModeVsCodeLmModelSelector : options.actModeVsCodeLmModelSelector,
})
case "cline": {
const configuredClineModelId = mode === "plan" ? options.planModeClineModelId : options.actModeClineModelId
const configuredClineModelInfo = mode === "plan" ? options.planModeClineModelInfo : options.actModeClineModelInfo
const clineModelId =
configuredClineModelId || (mode === "plan" ? options.planModeOpenRouterModelId : options.actModeOpenRouterModelId)
(mode === "plan" ? options.planModeClineModelId : options.actModeClineModelId) ||
(mode === "plan" ? options.planModeOpenRouterModelId : options.actModeOpenRouterModelId)
const clineModelInfo =
configuredClineModelInfo ||
(mode === "plan" ? options.planModeClineModelInfo : options.actModeClineModelInfo) ||
(mode === "plan" ? options.planModeOpenRouterModelInfo : options.actModeOpenRouterModelInfo)
return new ClineHandler({
onRetryAttempt: options.onRetryAttempt,
@@ -289,34 +278,6 @@ function createHandlerForProvider(
enableParallelToolCalling: options.enableParallelToolCalling,
})
}
case "cline-pass": {
const configuredClinePassModelId =
mode === "plan" ? options.planModeClinePassModelId : options.actModeClinePassModelId
const configuredClinePassModelInfo =
mode === "plan" ? options.planModeClinePassModelInfo : options.actModeClinePassModelInfo
// ClinePass users may also select Cline free models (OpenRouter-style ids without
// the cline-pass/ prefix), so pass any configured id through to the Cline API.
const clineModelId = configuredClinePassModelId || clinePassDefaultModelId
const clineModelInfo = resolveClinePassModelInfo(
clineModelId,
configuredClinePassModelInfo
? buildModelInfoNameMap({ [clineModelId]: configuredClinePassModelInfo })
: undefined,
)
return new ClineHandler({
onRetryAttempt: options.onRetryAttempt,
clineAccountId: options.clineAccountId,
clineApiKey: options.clineApiKey,
ulid: options.ulid,
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
openRouterProviderSorting: options.openRouterProviderSorting,
openRouterModelId: clineModelId,
openRouterModelInfo: clineModelInfo,
enableParallelToolCalling: options.enableParallelToolCalling,
})
}
case "litellm":
return new LiteLlmHandler({
onRetryAttempt: options.onRetryAttempt,
@@ -87,30 +87,6 @@ describe("AnthropicHandler", () => {
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", () => {
@@ -215,16 +215,6 @@ describe("AwsBedrockHandler", () => {
bedrockModels["anthropic.claude-opus-4-8:1m"].supportsGlobalEndpoint.should.equal(true)
})
it("should mark Bedrock Fable 5 variants as global-endpoint capable", () => {
bedrockModels["anthropic.claude-fable-5"].supportsGlobalEndpoint.should.equal(true)
bedrockModels["anthropic.claude-fable-5:1m"].supportsGlobalEndpoint.should.equal(true)
})
it("should mark Bedrock Sonnet 5 variants as global-endpoint capable", () => {
bedrockModels["anthropic.claude-sonnet-5"].supportsGlobalEndpoint.should.equal(true)
bedrockModels["anthropic.claude-sonnet-5:1m"].supportsGlobalEndpoint.should.equal(true)
})
it("should include Vertex Opus 4.7 variants in the derived global model list", () => {
vertexModels["claude-opus-4-7"].supportsGlobalEndpoint.should.equal(true)
vertexModels["claude-opus-4-7:1m"].supportsGlobalEndpoint.should.equal(true)
@@ -238,13 +228,6 @@ describe("AwsBedrockHandler", () => {
vertexGlobalModels.should.have.property("claude-opus-4-8")
vertexGlobalModels.should.have.property("claude-opus-4-8:1m")
})
it("should include Vertex Fable 5 variants in the derived global model list", () => {
vertexModels["claude-fable-5"].supportsGlobalEndpoint.should.equal(true)
vertexModels["claude-fable-5:1m"].supportsGlobalEndpoint.should.equal(true)
vertexGlobalModels.should.have.property("claude-fable-5")
vertexGlobalModels.should.have.property("claude-fable-5:1m")
})
})
const mockOptions: AwsBedrockHandlerOptions = {
@@ -995,19 +978,6 @@ describe("AwsBedrockHandler", () => {
modelId.should.equal("jp.anthropic.claude-sonnet-4-6")
})
it("should apply JP cross-region prefix for sonnet 5", async () => {
const jpOptions: AwsBedrockHandlerOptions = {
...mockOptions,
awsUseCrossRegionInference: true,
apiModelId: "anthropic.claude-sonnet-5",
awsRegion: "ap-northeast-1",
}
const jpHandler = new AwsBedrockHandler(jpOptions)
const modelId = await jpHandler.getModelId()
modelId.should.equal("jp.anthropic.claude-sonnet-5")
})
it("should apply global cross-region prefix for supported models", async () => {
const globalOptions: AwsBedrockHandlerOptions = {
...mockOptions,
@@ -416,46 +416,6 @@ describe("ClaudeCodeHandler", () => {
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 Sonnet 5 model id", () => {
const handler = new ClaudeCodeHandler({
apiModelId: "claude-sonnet-5",
})
const model = handler.getModel()
model.id.should.equal("claude-sonnet-5")
model.info.contextWindow.should.equal(200_000)
})
it("should support Sonnet 5 1m model id", () => {
const handler = new ClaudeCodeHandler({
apiModelId: "claude-sonnet-5[1m]",
})
const model = handler.getModel()
model.id.should.equal("claude-sonnet-5[1m]")
model.info.contextWindow.should.equal(1_000_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]",
@@ -3,7 +3,6 @@ import { openRouterDefaultModelInfo } from "@shared/api"
import sinon from "sinon"
import { ClineAccountService } from "@/services/account/ClineAccountService"
import { AuthService } from "@/services/auth/AuthService"
import { ClineError, ClineErrorType } from "@/services/error/ClineError"
import { ClineHandler } from "../cline"
describe("ClineHandler", () => {
@@ -164,34 +163,4 @@ describe("ClineHandler", () => {
payload.messages[0].content[0].cache_control.should.deepEqual({ type: "ephemeral" })
payload.messages[1].content[0].cache_control.should.deepEqual({ type: "ephemeral" })
})
it("propagates a pre-stream 403 entitlement error so it classifies as Entitlement", async () => {
const handler = createHandler({})
// A 403 from ValidateModelEntitlement rejects completions.create() before streaming,
// matching the OpenAI SDK APIError shape (status + code + error body).
const apiError = Object.assign(new Error("403 the user is not subscribed to required model plan"), {
status: 403,
code: "ENTITLEMENT_ERROR",
error: {
code: "ENTITLEMENT_ERROR",
message: "Error 403: the user is not subscribed to required model plan",
},
})
const fakeClient = { chat: { completions: { create: sinon.stub().rejects(apiError) } } }
sinon.stub(handler as any, "ensureClient").resolves(fakeClient as any)
sinon.stub(handler as any, "getFreeModelIdSet").resolves(new Set())
sinon.stub(handler, "getModel").returns({ id: "cline-pass/glm-5.2", info: openRouterDefaultModelInfo })
let thrown: unknown
try {
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
// drain
}
} catch (e) {
thrown = e
}
const clineError = ClineError.transform(thrown, "cline-pass/glm-5.2", "cline-pass")
clineError.isErrorType(ClineErrorType.Entitlement).should.be.true()
})
})
@@ -1,43 +0,0 @@
import "should";
import {
huggingFaceDefaultModelId,
huggingFaceModels,
} from "../../../../shared/api";
import { HuggingFaceHandler } from "../huggingface";
describe("HuggingFaceHandler", () => {
it("uses dynamic Hugging Face model info for models outside the static list", () => {
const modelInfo = {
maxTokens: 8192,
contextWindow: 128_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Available on providers: test-provider",
};
const handler = new HuggingFaceHandler({
huggingFaceApiKey: "test-api-key",
huggingFaceModelId: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
huggingFaceModelInfo: modelInfo,
});
handler.getModel().should.deepEqual({
id: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
info: modelInfo,
});
});
it("preserves unknown model IDs when model info is unavailable", () => {
const handler = new HuggingFaceHandler({
huggingFaceApiKey: "test-api-key",
huggingFaceModelId: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
});
handler.getModel().should.deepEqual({
id: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
info: huggingFaceModels[huggingFaceDefaultModelId],
});
});
});
@@ -1,104 +0,0 @@
import "should"
import type OpenAI from "openai"
import sinon from "sinon"
import type { ApiStreamChunk } from "../../transform/stream"
import { OpenAiHandler } from "../openai"
describe("OpenAiHandler", () => {
afterEach(() => {
sinon.restore()
})
const createAsyncIterable = (data: unknown[] = []) => ({
[Symbol.asyncIterator]: async function* () {
yield* data
},
})
const createHandler = (chunks: unknown[]) => {
const handler = new OpenAiHandler({
openAiApiKey: "test-api-key",
openAiModelId: "test-model",
})
const fakeClient = {
chat: {
completions: {
create: sinon.stub().resolves(createAsyncIterable(chunks)),
},
},
}
sinon.stub(handler as unknown as { ensureClient: () => OpenAI }, "ensureClient").returns(fakeClient as unknown as OpenAI)
return handler
}
it("should emit the latest cumulative usage snapshot only once", async () => {
const handler = createHandler([
{
choices: [{ delta: { content: "O" } }],
usage: {
prompt_tokens: 48,
completion_tokens: 1,
total_tokens: 49,
prompt_tokens_details: { cached_tokens: 32 },
},
},
{
choices: [{ delta: { content: "K" } }],
usage: {
prompt_tokens: 48,
completion_tokens: 2,
total_tokens: 50,
prompt_tokens_details: { cached_tokens: 32 },
},
},
{ choices: [{ delta: {}, finish_reason: "stop" }], usage: null },
])
const chunks: ApiStreamChunk[] = []
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
chunks.push(chunk)
}
chunks.should.deepEqual([
{ type: "text", text: "O" },
{ type: "text", text: "K" },
{
type: "usage",
inputTokens: 48,
outputTokens: 2,
cacheReadTokens: 32,
cacheWriteTokens: 0,
},
])
})
it("should preserve providers that emit usage only on the final chunk", async () => {
const handler = createHandler([
{ choices: [{ delta: { content: "OK" } }] },
{
choices: [],
usage: {
prompt_tokens: 17,
completion_tokens: 9,
total_tokens: 26,
},
},
])
const chunks: ApiStreamChunk[] = []
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
chunks.push(chunk)
}
chunks.should.deepEqual([
{ type: "text", text: "OK" },
{
type: "usage",
inputTokens: 17,
outputTokens: 9,
cacheReadTokens: 0,
cacheWriteTokens: 0,
},
])
})
})
@@ -69,7 +69,6 @@ describe("SapAiCoreHandler", () => {
it("should support different Claude model variants", () => {
const modelVariants = [
"anthropic--claude-sonnet-5",
"anthropic--claude-4.6-sonnet",
"anthropic--claude-4-sonnet",
"anthropic--claude-4-opus",
@@ -134,11 +134,9 @@ interface ProviderChainOptions {
profile?: string
}
// a special jp inference profile was created for newer Claude CRIS models
// a special jp inference profile was created for sonnet 4.6, opus 4.6, sonnet 4.5 & haiku 4.5
// https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html
const JP_SUPPORTED_CRIS_MODELS = [
"anthropic.claude-sonnet-5",
"anthropic.claude-sonnet-5:1m",
"anthropic.claude-sonnet-4-6",
"anthropic.claude-sonnet-4-6:1m",
"anthropic.claude-opus-4-6-v1",
+3 -8
View File
@@ -1,4 +1,4 @@
import { clinePassModels, type ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "@shared/api"
import { type ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "@shared/api"
import { shouldSkipReasoningForModel } from "@utils/model-utils"
import axios from "axios"
import OpenAI from "openai"
@@ -37,10 +37,7 @@ function normalizeModelId(modelId: string): string {
return modelId.trim().toLowerCase()
}
const CLINE_FREE_MODEL_IDS = new Set([
...CLINE_RECOMMENDED_MODELS_FALLBACK.free.map((model) => normalizeModelId(model.id)),
...Object.keys(clinePassModels).map((modelId) => normalizeModelId(modelId)),
])
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
@@ -70,9 +67,7 @@ export class ClineHandler implements ApiHandler {
private async getFreeModelIdSet(): Promise<Set<string>> {
try {
const models = await refreshClineRecommendedModels()
const freeModelIds = [...models.free, ...models.clinePass]
.map((model) => normalizeModelId(model.id))
.filter((modelId) => modelId.length > 0)
const freeModelIds = models.free.map((model) => normalizeModelId(model.id)).filter((modelId) => modelId.length > 0)
if (freeModelIds.length > 0) {
return new Set(freeModelIds)
}
+10 -20
View File
@@ -1,10 +1,7 @@
import { DeepSeekModelId, deepSeekDefaultModelId, deepSeekModels, ModelInfo } from "@shared/api"
import { calculateApiCostOpenAI } from "@utils/cost"
import OpenAI from "openai"
import type {
ChatCompletionReasoningEffort,
ChatCompletionTool as OpenAITool,
} from "openai/resources/chat/completions"
import 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"
@@ -18,7 +15,6 @@ import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-p
interface DeepSeekHandlerOptions extends CommonApiHandlerOptions {
deepSeekApiKey?: string
apiModelId?: string
reasoningEffort?: string
}
export class DeepSeekHandler implements ApiHandler {
@@ -85,12 +81,11 @@ export class DeepSeekHandler implements ApiHandler {
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"
model.id.includes("deepseek-reasoner") || model.id === "deepseek-v4-flash" || model.id === "deepseek-v4-pro"
const convertedMessages = convertToOpenAiMessages(messages)
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = isDeepSeekReasonerModel
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = isDeepSeekThinkingModel
? [{ role: "system", content: systemPrompt }, ...addReasoningContent(convertedMessages, messages)]
: [{ role: "system", content: systemPrompt }, ...convertedMessages]
@@ -102,11 +97,6 @@ export class DeepSeekHandler implements ApiHandler {
stream_options: { include_usage: true },
// Only set temperature for non-thinking models
...(isDeepSeekThinkingModel ? {} : { temperature: 0 }),
// DeepSeek thinking models accept reasoning effort (low/medium map to high, xhigh maps to max).
// "none" isn't a valid DeepSeek value, so omit it and let the API use its default.
...(isDeepSeekThinkingModel && this.options.reasoningEffort && this.options.reasoningEffort !== "none"
? { reasoning_effort: this.options.reasoningEffort as ChatCompletionReasoningEffort }
: {}),
...getOpenAIToolParams(tools),
})
@@ -114,13 +104,6 @@ export class DeepSeekHandler implements ApiHandler {
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",
@@ -132,6 +115,13 @@ export class DeepSeekHandler implements ApiHandler {
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
}
if (delta && "reasoning_content" in delta && delta.reasoning_content) {
yield {
type: "reasoning",
reasoning: (delta.reasoning_content as string | undefined) || "",
}
}
if (chunk.usage) {
yield* this.yieldUsage(model.info, chunk.usage)
}
+2 -6
View File
@@ -8,7 +8,7 @@ import {
FunctionDeclaration as GoogleTool,
ThinkingLevel,
} from "@google/genai"
import { GeminiModelId, geminiDefaultModelId, geminiModels, getVertexCustomModelInfo, ModelInfo } from "@shared/api"
import { GeminiModelId, geminiDefaultModelId, geminiModels, ModelInfo } from "@shared/api"
import { GEMINI_FLASH_MAX_OUTPUT_TOKENS, isGeminiFlashModel } from "@utils/model-utils"
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
import { telemetryService } from "@/services/telemetry"
@@ -25,7 +25,6 @@ interface GeminiHandlerOptions extends CommonApiHandlerOptions {
isVertex?: boolean
vertexProjectId?: string
vertexRegion?: string
vertexCustomModelInfo?: ModelInfo
geminiApiKey?: string
geminiBaseUrl?: string
thinkingBudgetTokens?: number
@@ -465,15 +464,12 @@ export class GeminiHandler implements ApiHandler {
/**
* Get the model ID and info for the current configuration
*/
getModel(): { id: string; info: ModelInfo } {
getModel(): { id: GeminiModelId; info: ModelInfo } {
const modelId = this.options.apiModelId
if (modelId && modelId in geminiModels) {
const id = modelId as GeminiModelId
return { id, info: geminiModels[id] }
}
if (modelId && this.options.isVertex) {
return { id: modelId, info: getVertexCustomModelInfo(this.options.vertexCustomModelInfo) }
}
return {
id: geminiDefaultModelId,
info: geminiModels[geminiDefaultModelId],
@@ -1,66 +1,56 @@
import {
huggingFaceDefaultModelId,
huggingFaceModels,
type ModelInfo,
} from "@shared/api";
import { calculateApiCostOpenAI } from "@utils/cost";
import type OpenAI from "openai";
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions";
import type { ClineStorageMessage } from "@/shared/messages/content";
import { createOpenAIClient } from "@/shared/net";
import type { ApiHandler, CommonApiHandlerOptions } from "../";
import { withRetry } from "../retry";
import { convertToOpenAiMessages } from "../transform/openai-format";
import type { ApiStream } from "../transform/stream";
import {
getOpenAIToolParams,
ToolCallProcessor,
} from "../transform/tool-call-processor";
import { HuggingFaceModelId, huggingFaceDefaultModelId, huggingFaceModels, ModelInfo } from "@shared/api"
import { calculateApiCostOpenAI } from "@utils/cost"
import OpenAI from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { ClineStorageMessage } from "@/shared/messages/content"
import { createOpenAIClient } from "@/shared/net"
import { ApiHandler, CommonApiHandlerOptions } from "../"
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
interface HuggingFaceHandlerOptions extends CommonApiHandlerOptions {
huggingFaceApiKey?: string;
huggingFaceModelId?: string;
huggingFaceModelInfo?: ModelInfo;
huggingFaceApiKey?: string
huggingFaceModelId?: string
huggingFaceModelInfo?: ModelInfo
}
export class HuggingFaceHandler implements ApiHandler {
private options: HuggingFaceHandlerOptions;
private client: OpenAI | undefined;
private cachedModel: { id: string; info: ModelInfo } | undefined;
private options: HuggingFaceHandlerOptions
private client: OpenAI | undefined
private cachedModel: { id: HuggingFaceModelId; info: ModelInfo } | undefined
constructor(options: HuggingFaceHandlerOptions) {
this.options = options;
this.options = options
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.huggingFaceApiKey) {
throw new Error("Hugging Face API key is required");
throw new Error("Hugging Face API key is required")
}
try {
this.client = createOpenAIClient({
baseURL: "https://router.huggingface.co/v1",
apiKey: this.options.huggingFaceApiKey,
});
})
} catch (error: any) {
throw new Error(`Error creating Hugging Face client: ${error.message}`);
throw new Error(`Error creating Hugging Face client: ${error.message}`)
}
}
return this.client;
return this.client
}
private async *yieldUsage(
info: ModelInfo,
usage: OpenAI.Completions.CompletionUsage | undefined,
): ApiStream {
private async *yieldUsage(info: ModelInfo, usage: OpenAI.Completions.CompletionUsage | undefined): ApiStream {
if (!usage) {
return;
return
}
const inputTokens = usage.prompt_tokens || 0;
const outputTokens = usage.completion_tokens || 0;
const totalCost = calculateApiCostOpenAI(info, inputTokens, outputTokens);
const inputTokens = usage.prompt_tokens || 0
const outputTokens = usage.completion_tokens || 0
const totalCost = calculateApiCostOpenAI(info, inputTokens, outputTokens)
const usageData = {
type: "usage" as const,
@@ -69,25 +59,21 @@ export class HuggingFaceHandler implements ApiHandler {
cacheWriteTokens: 0,
cacheReadTokens: 0,
totalCost: totalCost,
};
}
yield usageData;
yield usageData
}
@withRetry()
async *createMessage(
systemPrompt: string,
messages: ClineStorageMessage[],
tools?: OpenAITool[],
): ApiStream {
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: OpenAITool[]): ApiStream {
try {
const client = this.ensureClient();
const model = this.getModel();
const client = this.ensureClient()
const model = this.getModel()
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
];
]
const requestParams = {
model: model.id,
@@ -97,71 +83,66 @@ export class HuggingFaceHandler implements ApiHandler {
stream_options: { include_usage: true },
temperature: 0,
...getOpenAIToolParams(tools),
};
}
const toolCallProcessor = new ToolCallProcessor();
const stream = (await client.chat.completions.create(
requestParams,
)) as any;
const toolCallProcessor = new ToolCallProcessor()
const stream = (await client.chat.completions.create(requestParams)) as any
let _chunkCount = 0;
let _totalContent = "";
let _chunkCount = 0
let _totalContent = ""
for await (const chunk of stream) {
_chunkCount++;
const delta = chunk.choices?.[0]?.delta;
_chunkCount++
const delta = chunk.choices?.[0]?.delta
if (delta?.content) {
_totalContent += delta.content;
_totalContent += delta.content
yield {
type: "text",
text: delta.content,
};
}
}
if (delta?.tool_calls) {
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls);
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
}
if (chunk.usage) {
yield* this.yieldUsage(model.info, chunk.usage);
yield* this.yieldUsage(model.info, chunk.usage)
}
}
} catch (error: any) {
throw error;
throw error
}
}
getModel(): { id: string; info: ModelInfo } {
getModel(): { id: HuggingFaceModelId; info: ModelInfo } {
// Return cached model if available
if (this.cachedModel) {
return this.cachedModel;
return this.cachedModel
}
const modelId = this.options.huggingFaceModelId;
let result: { id: string; info: ModelInfo };
const modelId = this.options.huggingFaceModelId
// List all available models for debugging
const _availableModels = Object.keys(huggingFaceModels)
let result: { id: HuggingFaceModelId; info: ModelInfo }
if (modelId && modelId in huggingFaceModels) {
const id = modelId as keyof typeof huggingFaceModels;
const modelInfo = huggingFaceModels[id];
result = { id, info: modelInfo };
} else if (modelId) {
const defaultInfo = huggingFaceModels[huggingFaceDefaultModelId];
result = {
id: modelId,
info: this.options.huggingFaceModelInfo || defaultInfo,
};
const id = modelId as HuggingFaceModelId
const modelInfo = huggingFaceModels[id]
result = { id, info: modelInfo }
} else {
const defaultInfo = huggingFaceModels[huggingFaceDefaultModelId];
const defaultInfo = huggingFaceModels[huggingFaceDefaultModelId]
result = {
id: huggingFaceDefaultModelId,
info: defaultInfo,
};
}
}
// Cache the result for future calls
this.cachedModel = result;
this.cachedModel = result
return result;
return result
}
}
+8 -13
View File
@@ -145,7 +145,6 @@ export class OpenAiHandler implements ApiHandler {
})
const toolCallProcessor = new ToolCallProcessor()
let latestUsage: OpenAI.Completions.CompletionUsage | undefined
for await (const chunk of stream) {
const delta = chunk.choices?.[0]?.delta
@@ -168,18 +167,14 @@ export class OpenAiHandler implements ApiHandler {
}
if (chunk.usage) {
latestUsage = chunk.usage
}
}
if (latestUsage) {
yield {
type: "usage",
inputTokens: latestUsage.prompt_tokens || 0,
outputTokens: latestUsage.completion_tokens || 0,
cacheReadTokens: latestUsage.prompt_tokens_details?.cached_tokens || 0,
// @ts-expect-error-next-line
cacheWriteTokens: latestUsage.prompt_cache_miss_tokens || 0,
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
// @ts-expect-error-next-line
cacheWriteTokens: chunk.usage.prompt_cache_miss_tokens || 0,
}
}
}
}
File diff suppressed because it is too large Load Diff
+3 -7
View File
@@ -1,7 +1,7 @@
import { Tool as AnthropicTool } from "@anthropic-ai/sdk/resources/index"
import { AnthropicVertex } from "@anthropic-ai/vertex-sdk"
import { FunctionDeclaration as GoogleTool } from "@google/genai"
import { CLAUDE_SONNET_1M_SUFFIX, getVertexCustomModelInfo, ModelInfo, vertexDefaultModelId, vertexModels } from "@shared/api"
import { CLAUDE_SONNET_1M_SUFFIX, ModelInfo, VertexModelId, vertexDefaultModelId, vertexModels } from "@shared/api"
import { isClaudeOpusAdaptiveThinkingModel, resolveClaudeOpusAdaptiveThinking } from "@shared/utils/reasoning-support"
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
import { ClineStorageMessage } from "@/shared/messages/content"
@@ -16,7 +16,6 @@ interface VertexHandlerOptions extends CommonApiHandlerOptions {
vertexProjectId?: string
vertexRegion?: string
apiModelId?: string
vertexCustomModelInfo?: ModelInfo
thinkingBudgetTokens?: number
geminiApiKey?: string
geminiBaseUrl?: string
@@ -268,15 +267,12 @@ export class VertexHandler implements ApiHandler {
}
}
getModel(): { id: string; info: ModelInfo } {
getModel(): { id: VertexModelId; info: ModelInfo } {
const modelId = this.options.apiModelId
if (modelId && modelId in vertexModels) {
const id = modelId as keyof typeof vertexModels
const id = modelId as VertexModelId
return { id, info: vertexModels[id] }
}
if (modelId) {
return { id: modelId, info: getVertexCustomModelInfo(this.options.vertexCustomModelInfo) }
}
return {
id: vertexDefaultModelId,
info: vertexModels[vertexDefaultModelId],
@@ -78,19 +78,6 @@ describe("createOpenRouterStream", () => {
payload.should.not.have.property("max_tokens")
})
it("strips the custom Sonnet 5 1m suffix and pins Anthropic/Vertex routing", async () => {
const { client, create } = createClient()
await createOpenRouterStream(client as any, "system prompt", [{ role: "user", content: "hello" }] as any, {
id: "anthropic/claude-sonnet-5:1m",
info: createModelInfo(128_000),
})
const payload = create.firstCall.args[0] as any
payload.should.have.property("model", "anthropic/claude-sonnet-5")
payload.provider.should.deepEqual({ order: ["anthropic", "google-vertex/global"], allow_fallbacks: false })
})
it("adds cache_control blocks for Qwen models that require explicit OpenRouter caching", async () => {
for (const modelId of ["qwen/qwen3.6-plus", "qwen/qwen3.7-max"]) {
const { client, create } = createClient()
@@ -127,87 +114,4 @@ describe("createOpenRouterStream", () => {
should(payload.temperature).equal(undefined)
should(payload.top_p).equal(undefined)
})
it("includes reasoning for Claude budget-based reasoning models", async () => {
const { client, create } = createClient()
await createOpenRouterStream(
client as any,
"system prompt",
[{ role: "user", content: "hello" }] as any,
{
id: "anthropic/claude-sonnet-4.5",
info: createModelInfo(64_000),
},
undefined,
16_384,
)
const payload = create.firstCall.args[0] as any
payload.should.have.property("include_reasoning", true)
payload.reasoning.should.deepEqual({ max_tokens: 16_384 })
should(payload.temperature).equal(undefined)
})
it("sends reasoning effort instead of token budgets for supported OpenRouter/Cline model families", async () => {
for (const modelId of [
"zai/glm-5.2",
"z-ai/glm-5.2",
"moonshotai/kimi-k2-thinking",
"accounts/fireworks/models/minimax-m3",
"provider/mimo-vl",
"qwen/qwen3.7-max",
"deepseek/deepseek-r1",
]) {
const { client, create } = createClient()
await createOpenRouterStream(
client as any,
"system prompt",
[{ role: "user", content: "hello" }] as any,
{
id: modelId,
info: { ...createModelInfo(131_072), thinkingConfig: { maxBudget: 16_384 } },
},
"high",
16_384,
)
const payload = create.firstCall.args[0] as any
payload.should.have.property("include_reasoning", true)
payload.reasoning.should.deepEqual({ effort: "high" })
}
})
it("does not send reasoning effort for ClinePass requests when unset", async () => {
const { client, create } = createClient()
await createOpenRouterStream(client as any, "system prompt", [{ role: "user", content: "hello" }] as any, {
id: "cline-pass/glm-5.2",
info: createModelInfo(131_072),
})
const payload = create.firstCall.args[0] as any
payload.should.have.property("include_reasoning", true)
payload.should.not.have.property("reasoning")
})
it("sends the selected reasoning effort for ClinePass requests", async () => {
const { client, create } = createClient()
await createOpenRouterStream(
client as any,
"system prompt",
[{ role: "user", content: "hello" }] as any,
{
id: "cline-pass/glm-5.2",
info: createModelInfo(131_072),
},
"high",
)
const payload = create.firstCall.args[0] as any
payload.should.have.property("include_reasoning", true)
payload.reasoning.should.deepEqual({ effort: "high" })
})
})
@@ -1,8 +1,5 @@
import type { Anthropic } from "@anthropic-ai/sdk";
import {
type ClineStorageMessage,
convertClineStorageToAnthropicMessage,
} from "@/shared/messages/content";
import { Anthropic } from "@anthropic-ai/sdk"
import { ClineStorageMessage, convertClineStorageToAnthropicMessage } from "@/shared/messages/content"
/**
* Converts Cline storage messages to Anthropic API format with optional cache control.
@@ -15,7 +12,7 @@ import {
* @returns Array of Anthropic-compatible messages with cache control applied
*/
export function sanitizeAnthropicMessages(
clineMessages: ClineStorageMessage[],
clineMessages: Array<ClineStorageMessage | Anthropic.MessageParam>,
supportCache: boolean,
): Array<Anthropic.MessageParam> {
// The latest message will be the new user message, one before will be the assistant message from a previous request,
@@ -24,37 +21,32 @@ export function sanitizeAnthropicMessages(
// know the last message to retrieve from the cache for the current request.
const userMsgIndices = clineMessages.reduce((acc, msg, index) => {
if (msg.role === "user") {
acc.push(index);
acc.push(index)
}
return acc;
}, [] as number[]);
return acc
}, [] as number[])
// Set to -1 if there are no user messages so the indices are invalid
const indicesLength = userMsgIndices.length ?? -1;
const lastUserMsgIndex = userMsgIndices[indicesLength - 1];
const secondLastMsgUserIndex = userMsgIndices[indicesLength - 2];
const indicesLength = userMsgIndices.length ?? -1
const lastUserMsgIndex = userMsgIndices[indicesLength - 1]
const secondLastMsgUserIndex = userMsgIndices[indicesLength - 2]
return clineMessages.map((msg, index) => {
const anthropicMsg = convertClineStorageToAnthropicMessage(msg);
const anthropicMsg = convertClineStorageToAnthropicMessage(msg)
// Add cache control to the last two user messages
if (
supportCache &&
(index === lastUserMsgIndex || index === secondLastMsgUserIndex)
) {
return addCacheControl(anthropicMsg);
if (supportCache && (index === lastUserMsgIndex || index === secondLastMsgUserIndex)) {
return addCacheControl(anthropicMsg)
}
return anthropicMsg;
});
return anthropicMsg
})
}
const isThinkingBlock = (
block: Anthropic.ContentBlockParam,
): block is
| Anthropic.Messages.ThinkingBlockParam
| Anthropic.Messages.RedactedThinkingBlockParam => {
return block.type === "thinking" || block.type === "redacted_thinking";
};
): block is Anthropic.Messages.ThinkingBlockParam | Anthropic.Messages.RedactedThinkingBlockParam => {
return block.type === "thinking" || block.type === "redacted_thinking"
}
/**
* Adds ephemeral cache control to the last content block of a message.
@@ -63,9 +55,7 @@ const isThinkingBlock = (
* @param message - The Anthropic message to add cache control to
* @returns A new message with cache control added to the last content block
*/
function addCacheControl(
message: Anthropic.MessageParam,
): Anthropic.MessageParam {
function addCacheControl(message: Anthropic.MessageParam): Anthropic.MessageParam {
// Convert string content to array format
if (typeof message.content === "string") {
return {
@@ -77,24 +67,24 @@ function addCacheControl(
cache_control: { type: "ephemeral" },
} satisfies Anthropic.TextBlockParam,
],
};
}
}
// Handle array content - add cache control to the last block
const content = [...message.content];
const lastIndex = content.length - 1;
const content = [...message.content]
const lastIndex = content.length - 1
if (lastIndex >= 0) {
const lastBlock = content[lastIndex];
const lastBlock = content[lastIndex]
// Only add cache_control to block types that support it (not ThinkingBlockParam)
if (!isThinkingBlock(lastBlock)) {
content[lastIndex] = {
...lastBlock,
cache_control: { type: "ephemeral" },
} satisfies Anthropic.ContentBlockParam;
} satisfies Anthropic.ContentBlockParam
}
}
return { ...message, content };
return { ...message, content }
}
@@ -1,6 +1,6 @@
import type { Anthropic } from "@anthropic-ai/sdk";
import type { Content, GenerateContentResponse, Part } from "@google/genai";
import type { ClineStorageMessage } from "@/shared/messages/content";
import { Anthropic } from "@anthropic-ai/sdk"
import { Content, GenerateContentResponse, Part } from "@google/genai"
import { ClineStorageMessage } from "@/shared/messages/content"
// Source: https://ai.google.dev/gemini-api/docs/thought-signatures#faqs
// While injecting custom function call blocks into the request is strongly discouraged,
@@ -8,29 +8,27 @@ import type { ClineStorageMessage } from "@/shared/messages/content";
// calls and responses that were executed deterministically by the client, or transferring a
// trace from a different model that does not include thought signatures, you can set the following dummy signatures of either
// "context_engineering_is_the_way_to_go" or "skip_thought_signature_validator" in the thought signature field to skip validation.
const GEMINI_DUMMY_THOUGHT_SIGNATURE = "skip_thought_signature_validator";
const GEMINI_DUMMY_THOUGHT_SIGNATURE = "skip_thought_signature_validator"
export function convertAnthropicContentToGemini(
content: string | ClineStorageMessage["content"],
): Part[] {
export function convertAnthropicContentToGemini(content: string | ClineStorageMessage["content"]): Part[] {
if (typeof content === "string") {
return [{ text: content }];
return [{ text: content }]
}
return content
.flatMap((block): Part | undefined => {
switch (block.type) {
case "text":
return { text: block.text, thoughtSignature: block.signature };
return { text: block.text, thoughtSignature: block.signature }
case "image":
if (block.source.type !== "base64") {
throw new Error("Unsupported image source type");
throw new Error("Unsupported image source type")
}
return {
inlineData: {
data: block.source.data,
mimeType: block.source.media_type,
},
};
}
case "tool_use":
return {
functionCall: {
@@ -39,7 +37,7 @@ export function convertAnthropicContentToGemini(
},
// Thought signature is required, so provide a dummy one if not present
thoughtSignature: block.signature || GEMINI_DUMMY_THOUGHT_SIGNATURE,
};
}
case "tool_result":
return {
functionResponse: {
@@ -48,66 +46,57 @@ export function convertAnthropicContentToGemini(
result: block.content,
},
},
};
}
case "thinking":
return {
text: block.thinking,
thought: true,
thoughtSignature: block.signature || GEMINI_DUMMY_THOUGHT_SIGNATURE,
};
}
default:
return undefined;
return undefined
}
})
.filter((part): part is Part => part !== undefined); // Filter out unsupported blocks
.filter((part): part is Part => part !== undefined) // Filter out unsupported blocks
}
export function convertAnthropicMessageToGemini(
message: ClineStorageMessage,
): Content {
export function convertAnthropicMessageToGemini(message: Anthropic.Messages.MessageParam): Content {
return {
role: message.role === "assistant" ? "model" : "user",
parts: convertAnthropicContentToGemini(message.content),
};
}
}
/*
It looks like gemini likes to double escape certain characters when writing file contents: https://discuss.ai.google.dev/t/function-call-string-property-is-double-escaped/37867
*/
export function unescapeGeminiContent(content: string) {
return content
.replace(/\\n/g, "\n")
.replace(/\\'/g, "'")
.replace(/\\"/g, '"')
.replace(/\\r/g, "\r")
.replace(/\\t/g, "\t");
return content.replace(/\\n/g, "\n").replace(/\\'/g, "'").replace(/\\"/g, '"').replace(/\\r/g, "\r").replace(/\\t/g, "\t")
}
export function convertGeminiResponseToAnthropic(
response: GenerateContentResponse,
): Anthropic.Messages.Message {
const content: Anthropic.Messages.ContentBlock[] = [];
export function convertGeminiResponseToAnthropic(response: GenerateContentResponse): Anthropic.Messages.Message {
const content: Anthropic.Messages.ContentBlock[] = []
const text = response.text;
const text = response.text
if (text) {
content.push({ type: "text", text, citations: null });
content.push({ type: "text", text, citations: null })
}
let stop_reason: Anthropic.Messages.Message["stop_reason"] = null;
const finishReason = response.candidates?.[0]?.finishReason;
let stop_reason: Anthropic.Messages.Message["stop_reason"] = null
const finishReason = response.candidates?.[0]?.finishReason
if (finishReason) {
switch (finishReason) {
case "STOP":
stop_reason = "end_turn";
break;
stop_reason = "end_turn"
break
case "MAX_TOKENS":
stop_reason = "max_tokens";
break;
stop_reason = "max_tokens"
break
case "SAFETY":
case "RECITATION":
case "OTHER":
stop_reason = "stop_sequence";
break;
stop_reason = "stop_sequence"
break
}
}
@@ -124,7 +113,6 @@ export function convertGeminiResponseToAnthropic(
output_tokens: response.usageMetadata?.candidatesTokenCount ?? 0,
cache_creation_input_tokens: null,
cache_read_input_tokens: null,
server_tool_use: null,
},
};
}
}
@@ -1,32 +1,29 @@
import type { Anthropic } from "@anthropic-ai/sdk";
import type { AssistantMessage } from "@mistralai/mistralai/models/components/assistantmessage";
import type { SystemMessage } from "@mistralai/mistralai/models/components/systemmessage";
import type { ToolMessage } from "@mistralai/mistralai/models/components/toolmessage";
import type { UserMessage } from "@mistralai/mistralai/models/components/usermessage";
import { getImageDataUrl } from "@/shared/messages/content";
import { Anthropic } from "@anthropic-ai/sdk"
import { AssistantMessage } from "@mistralai/mistralai/models/components/assistantmessage"
import { SystemMessage } from "@mistralai/mistralai/models/components/systemmessage"
import { ToolMessage } from "@mistralai/mistralai/models/components/toolmessage"
import { UserMessage } from "@mistralai/mistralai/models/components/usermessage"
export type MistralMessage =
| (SystemMessage & { role: "system" })
| (UserMessage & { role: "user" })
| (AssistantMessage & { role: "assistant" })
| (ToolMessage & { role: "tool" });
| (ToolMessage & { role: "tool" })
export function convertToMistralMessages(
anthropicMessages: Anthropic.Messages.MessageParam[],
): MistralMessage[] {
const mistralMessages: MistralMessage[] = [];
export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.MessageParam[]): MistralMessage[] {
const mistralMessages: MistralMessage[] = []
for (const anthropicMessage of anthropicMessages) {
if (typeof anthropicMessage.content === "string") {
mistralMessages.push({
role: anthropicMessage.role,
content: anthropicMessage.content,
});
})
} else {
if (anthropicMessage.role === "user") {
// Filter to only include text and image blocks
const textAndImageBlocks = anthropicMessage.content.filter(
(part) => part.type === "text" || part.type === "image",
);
)
if (textAndImageBlocks.length > 0) {
mistralMessages.push({
@@ -36,31 +33,29 @@ export function convertToMistralMessages(
return {
type: "image_url",
imageUrl: {
url: getImageDataUrl(part.source),
url: `data:${part.source.media_type};base64,${part.source.data}`,
},
};
}
}
return { type: "text", text: part.text };
return { type: "text", text: part.text }
}),
});
})
}
} else if (anthropicMessage.role === "assistant") {
// Only process text blocks - assistant cannot send images or other content types in Mistral's API format
const textBlocks = anthropicMessage.content.filter(
(part) => part.type === "text",
);
const textBlocks = anthropicMessage.content.filter((part) => part.type === "text")
if (textBlocks.length > 0) {
const content = textBlocks.map((part) => part.text).join("\n");
const content = textBlocks.map((part) => part.text).join("\n")
mistralMessages.push({
role: "assistant",
content,
});
})
}
}
}
}
return mistralMessages;
return mistralMessages
}
+75 -90
View File
@@ -1,6 +1,6 @@
import type { Anthropic } from "@anthropic-ai/sdk";
import type OpenAI from "openai";
import { Logger } from "@/shared/services/Logger";
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { Logger } from "@/shared/services/Logger"
const o1SystemPrompt = (systemPrompt: string) => `
# System Prompt
@@ -164,7 +164,7 @@ I've analyzed the project structure, but I need more information to proceed. Let
<ask_followup_question>
<question>Which specific feature would you like me to implement in the example.py file?</question>
</ask_followup_question>
`;
`
export function convertToO1Messages(
openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[],
@@ -176,26 +176,26 @@ export function convertToO1Messages(
acc.push({
role: "user",
content: message.content || "",
});
})
} else if (message.role === "assistant" && message.tool_calls) {
// Convert tool calls to content and remove tool_calls
let content = message.content || "";
let content = message.content || ""
message.tool_calls.forEach((toolCall) => {
if (toolCall.type === "function") {
content += `\nTool Call: ${toolCall.function.name}\nArguments: ${toolCall.function.arguments}`;
content += `\nTool Call: ${toolCall.function.name}\nArguments: ${toolCall.function.arguments}`
}
});
})
acc.push({
role: "assistant",
content: content,
tool_calls: undefined,
});
})
} else {
// Keep other messages as they are
acc.push(message);
acc.push(message)
}
return acc;
}, [] as OpenAI.Chat.ChatCompletionMessageParam[]);
return acc
}, [] as OpenAI.Chat.ChatCompletionMessageParam[])
// Find the index of the last assistant message
// const lastAssistantIndex = findLastIndex(toolsReplaced, (message) => message.role === "assistant")
@@ -207,7 +207,7 @@ export function convertToO1Messages(
content: o1SystemPrompt(systemPrompt),
} as OpenAI.Chat.ChatCompletionUserMessageParam,
...toolsReplaced,
];
]
// If there's an assistant message, insert the system prompt after it
// if (lastAssistantIndex !== -1) {
@@ -226,12 +226,12 @@ export function convertToO1Messages(
// })
// }
return messagesWithSystemPrompt;
return messagesWithSystemPrompt
}
interface ToolCall {
tool: string;
tool_input: Record<string, string>;
tool: string
tool_input: Record<string, string>
}
const toolNames = [
@@ -243,116 +243,106 @@ const toolNames = [
"write_to_file",
"ask_followup_question",
"attempt_completion",
];
]
function parseAIResponse(response: string): {
normalText: string;
toolCalls: ToolCall[];
normalText: string
toolCalls: ToolCall[]
} {
// Create a regex pattern to match any tool call opening tag
const toolCallPattern = new RegExp(`<(${toolNames.join("|")})`, "i");
const match = response.match(toolCallPattern);
const toolCallPattern = new RegExp(`<(${toolNames.join("|")})`, "i")
const match = response.match(toolCallPattern)
if (!match) {
// No tool calls found
return { normalText: response.trim(), toolCalls: [] };
return { normalText: response.trim(), toolCalls: [] }
}
const toolCallStart = match.index!;
const normalText = response.slice(0, toolCallStart).trim();
const toolCallsText = response.slice(toolCallStart);
const toolCallStart = match.index!
const normalText = response.slice(0, toolCallStart).trim()
const toolCallsText = response.slice(toolCallStart)
const toolCalls = parseToolCalls(toolCallsText);
const toolCalls = parseToolCalls(toolCallsText)
return { normalText, toolCalls };
return { normalText, toolCalls }
}
function parseToolCalls(toolCallsText: string): ToolCall[] {
const toolCalls: ToolCall[] = [];
const toolCalls: ToolCall[] = []
let remainingText = toolCallsText;
let remainingText = toolCallsText
while (remainingText.length > 0) {
const toolMatch = toolNames.find((tool) =>
new RegExp(`<${tool}`, "i").test(remainingText),
);
const toolMatch = toolNames.find((tool) => new RegExp(`<${tool}`, "i").test(remainingText))
if (!toolMatch) {
break; // No more tool calls found
break // No more tool calls found
}
const startTag = `<${toolMatch}`;
const endTag = `</${toolMatch}>`;
const startIndex = remainingText.indexOf(startTag);
const endIndex = remainingText.indexOf(endTag, startIndex);
const startTag = `<${toolMatch}`
const endTag = `</${toolMatch}>`
const startIndex = remainingText.indexOf(startTag)
const endIndex = remainingText.indexOf(endTag, startIndex)
if (endIndex === -1) {
break; // Malformed XML, no closing tag found
break // Malformed XML, no closing tag found
}
const toolCallContent = remainingText.slice(
startIndex,
endIndex + endTag.length,
);
remainingText = remainingText.slice(endIndex + endTag.length).trim();
const toolCallContent = remainingText.slice(startIndex, endIndex + endTag.length)
remainingText = remainingText.slice(endIndex + endTag.length).trim()
const toolCall = parseToolCall(toolMatch, toolCallContent);
const toolCall = parseToolCall(toolMatch, toolCallContent)
if (toolCall) {
toolCalls.push(toolCall);
toolCalls.push(toolCall)
}
}
return toolCalls;
return toolCalls
}
function parseToolCall(toolName: string, content: string): ToolCall | null {
const tool_input: Record<string, string> = {};
const tool_input: Record<string, string> = {}
// Remove the outer tool tags
const innerContent = content
.replace(new RegExp(`^<${toolName}>|</${toolName}>$`, "g"), "")
.trim();
const innerContent = content.replace(new RegExp(`^<${toolName}>|</${toolName}>$`, "g"), "").trim()
// Parse nested XML elements
const paramRegex = /<(\w+)>([\s\S]*?)<\/\1>/gs;
let match: RegExpExecArray | null;
const paramRegex = /<(\w+)>([\s\S]*?)<\/\1>/gs
let match: RegExpExecArray | null
while ((match = paramRegex.exec(innerContent)) !== null) {
const [, paramName, paramValue] = match;
const [, paramName, paramValue] = match
// Preserve newlines and trim only leading/trailing whitespace
tool_input[paramName] = paramValue.replace(/^\s+|\s+$/g, "");
tool_input[paramName] = paramValue.replace(/^\s+|\s+$/g, "")
}
// Validate required parameters
if (!validateToolInput(toolName, tool_input)) {
Logger.error(`Invalid tool call for ${toolName}:`, content);
return null;
Logger.error(`Invalid tool call for ${toolName}:`, content)
return null
}
return { tool: toolName, tool_input };
return { tool: toolName, tool_input }
}
function validateToolInput(
toolName: string,
tool_input: Record<string, string>,
): boolean {
function validateToolInput(toolName: string, tool_input: Record<string, string>): boolean {
switch (toolName) {
case "execute_command":
return "command" in tool_input;
return "command" in tool_input
case "read_file":
case "list_code_definition_names":
case "list_files":
return "path" in tool_input;
return "path" in tool_input
case "search_files":
return "path" in tool_input && "regex" in tool_input;
return "path" in tool_input && "regex" in tool_input
case "write_to_file":
return "path" in tool_input && "content" in tool_input;
return "path" in tool_input && "content" in tool_input
case "ask_followup_question":
return "question" in tool_input;
return "question" in tool_input
case "attempt_completion":
return "result" in tool_input;
return "result" in tool_input
default:
return false;
return false
}
}
@@ -376,10 +366,8 @@ function validateToolInput(
export function convertO1ResponseToAnthropicMessage(
completion: OpenAI.Chat.Completions.ChatCompletion,
): Anthropic.Messages.Message {
const openAiMessage = completion.choices[0].message;
const { normalText, toolCalls } = parseAIResponse(
openAiMessage.content || "",
);
const openAiMessage = completion.choices[0].message
const { normalText, toolCalls } = parseAIResponse(openAiMessage.content || "")
const anthropicMessage: Anthropic.Messages.Message = {
id: completion.id,
@@ -396,14 +384,14 @@ export function convertO1ResponseToAnthropicMessage(
stop_reason: (() => {
switch (completion.choices[0].finish_reason) {
case "stop":
return "end_turn";
return "end_turn"
case "length":
return "max_tokens";
return "max_tokens"
case "tool_calls":
return "tool_use";
return "tool_use"
case "content_filter": // Anthropic doesn't have an exact equivalent
default:
return null;
return null
}
})(),
stop_sequence: null, // which custom stop_sequence was generated, if any (not applicable if you don't use stop_sequence)
@@ -412,26 +400,23 @@ export function convertO1ResponseToAnthropicMessage(
output_tokens: completion.usage?.completion_tokens || 0,
cache_creation_input_tokens: null,
cache_read_input_tokens: null,
server_tool_use: null,
},
};
}
if (toolCalls.length > 0) {
anthropicMessage.content.push(
...toolCalls.map(
(toolCall: ToolCall, index: number): Anthropic.ToolUseBlock => {
return {
type: "tool_use",
id: `call_${index}_${Date.now()}`, // Generate a unique ID for each tool call
name: toolCall.tool,
input: toolCall.tool_input,
};
},
),
);
...toolCalls.map((toolCall: ToolCall, index: number): Anthropic.ToolUseBlock => {
return {
type: "tool_use",
id: `call_${index}_${Date.now()}`, // Generate a unique ID for each tool call
name: toolCall.tool,
input: toolCall.tool_input,
}
}),
)
}
return anthropicMessage;
return anthropicMessage
}
// Example usage:
@@ -1,68 +1,64 @@
import type { Message } from "ollama";
import { Message } from "ollama"
import {
type ClineAssistantToolUseBlock,
type ClineImageContentBlock,
type ClineStorageMessage,
type ClineTextContentBlock,
type ClineUserToolResultContentBlock,
getImageDataUrl,
} from "@/shared/messages/content";
ClineAssistantToolUseBlock,
ClineImageContentBlock,
ClineStorageMessage,
ClineTextContentBlock,
ClineUserToolResultContentBlock,
} from "@/shared/messages/content"
export function convertToOllamaMessages(
anthropicMessages: Omit<ClineStorageMessage, "modelInfo">[],
): Message[] {
const ollamaMessages: Message[] = [];
export function convertToOllamaMessages(anthropicMessages: Omit<ClineStorageMessage, "modelInfo">[]): Message[] {
const ollamaMessages: Message[] = []
for (const anthropicMessage of anthropicMessages) {
if (typeof anthropicMessage.content === "string") {
ollamaMessages.push({
role: anthropicMessage.role,
content: anthropicMessage.content,
});
})
} else {
if (anthropicMessage.role === "user") {
const { nonToolMessages, toolMessages } =
anthropicMessage.content.reduce<{
nonToolMessages: (ClineTextContentBlock | ClineImageContentBlock)[];
toolMessages: ClineUserToolResultContentBlock[];
}>(
(acc, part) => {
if (part.type === "tool_result") {
acc.toolMessages.push(part);
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part);
}
return acc;
},
{ nonToolMessages: [], toolMessages: [] },
);
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (ClineTextContentBlock | ClineImageContentBlock)[]
toolMessages: ClineUserToolResultContentBlock[]
}>(
(acc, part) => {
if (part.type === "tool_result") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
}
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
// Process tool result messages FIRST since they must follow the tool use messages
const toolResultImages: string[] = [];
const toolResultImages: string[] = []
toolMessages.forEach((toolMessage) => {
// The Anthropic SDK allows tool results to be a string or an array of text and image blocks, enabling rich and structured content. In contrast, the Ollama SDK only supports tool results as a single string, so we map the Anthropic tool result parts into one concatenated string to maintain compatibility.
let content: string;
let content: string
if (typeof toolMessage.content === "string") {
content = toolMessage.content;
content = toolMessage.content
} else {
content =
toolMessage.content
?.map((part) => {
if (part.type === "image") {
toolResultImages.push(getImageDataUrl(part.source));
return "(see following user message for image)";
toolResultImages.push(`data:${part.source.media_type};base64,${part.source.data}`)
return "(see following user message for image)"
}
return part.text;
return part.text
})
.join("\n") ?? "";
.join("\n") ?? ""
}
ollamaMessages.push({
role: "user",
images: toolResultImages.length > 0 ? toolResultImages : undefined,
content: content,
});
});
})
})
// Process non-tool messages
if (nonToolMessages.length > 0) {
@@ -71,50 +67,49 @@ export function convertToOllamaMessages(
content: nonToolMessages
.map((part) => {
if (part.type === "image") {
return getImageDataUrl(part.source);
return `data:${part.source.media_type};base64,${part.source.data}`
}
return part.text;
return part.text
})
.join("\n"),
});
})
}
} else if (anthropicMessage.role === "assistant") {
const { nonToolMessages, toolMessages } =
anthropicMessage.content.reduce<{
nonToolMessages: (ClineTextContentBlock | ClineImageContentBlock)[];
toolMessages: ClineAssistantToolUseBlock[];
}>(
(acc, part) => {
if (part.type === "tool_use") {
acc.toolMessages.push(part);
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part);
} // assistant cannot send tool_result messages
return acc;
},
{ nonToolMessages: [], toolMessages: [] },
);
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (ClineTextContentBlock | ClineImageContentBlock)[]
toolMessages: ClineAssistantToolUseBlock[]
}>(
(acc, part) => {
if (part.type === "tool_use") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
} // assistant cannot send tool_result messages
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
// Process non-tool messages
let content: string = "";
let content: string = ""
if (nonToolMessages.length > 0) {
content = nonToolMessages
.map((part) => {
if (part.type === "image") {
return ""; // impossible as the assistant cannot send images
return "" // impossible as the assistant cannot send images
}
return part.text;
return part.text
})
.join("\n");
.join("\n")
}
ollamaMessages.push({
role: "assistant",
content,
});
})
}
}
}
return ollamaMessages;
return ollamaMessages
}
@@ -1,19 +1,19 @@
import type { Anthropic } from "@anthropic-ai/sdk";
import type OpenAI from "openai";
import type { ApiProvider } from "@/shared/api";
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { ApiProvider } from "@/shared/api"
import {
type ClineAssistantRedactedThinkingBlock,
type ClineAssistantThinkingBlock,
type ClineAssistantToolUseBlock,
type ClineImageContentBlock,
type ClineTextContentBlock,
type ClineUserToolResultContentBlock,
getImageDataUrl,
} from "@/shared/messages/content";
import { Logger } from "@/shared/services/Logger";
ClineAssistantRedactedThinkingBlock,
ClineAssistantThinkingBlock,
ClineAssistantToolUseBlock,
ClineImageContentBlock,
ClineStorageMessage,
ClineTextContentBlock,
ClineUserToolResultContentBlock,
} from "@/shared/messages/content"
import { Logger } from "@/shared/services/Logger"
// OpenAI API has a maximum tool call ID length of 40 characters
const MAX_TOOL_CALL_ID_LENGTH = 40;
const MAX_TOOL_CALL_ID_LENGTH = 40
/**
* Determines if a given tool ID follows the OpenAI Responses API format for tool calls.
@@ -23,7 +23,7 @@ const MAX_TOOL_CALL_ID_LENGTH = 40;
* @returns True if the tool ID matches the OpenAI Responses API format, false otherwise
*/
function isOpenAIResponseToolId(callId: string): boolean {
return callId.startsWith("fc_") && callId.length === 53;
return callId.startsWith("fc_") && callId.length === 53
}
/**
@@ -37,24 +37,21 @@ function isOpenAIResponseToolId(callId: string): boolean {
* @param provider - The API provider that the OpenAI formatted messages will be sent to
* @returns The transformed ID suitable for OpenAI API
*/
function transformToolCallIdForNativeApi(
toolId: string,
provider?: ApiProvider,
): string {
function transformToolCallIdForNativeApi(toolId: string, provider?: ApiProvider): string {
// OpenAI Responses API uses "fc_" prefix with 53 char length
// Convert these to "call_" prefix format for Chat Completions API
if (isOpenAIResponseToolId(toolId)) {
// Use the last 33 chars + "call_" (5 chars) to stay under the 40-char limit.
return `call_${toolId.slice(toolId.length - (MAX_TOOL_CALL_ID_LENGTH - 5))}`;
return `call_${toolId.slice(toolId.length - (MAX_TOOL_CALL_ID_LENGTH - 5))}`
}
if (provider !== "openai-native") {
return toolId;
return toolId
}
// Ensure ID doesn't exceed max length
if (toolId.length > MAX_TOOL_CALL_ID_LENGTH) {
return toolId.slice(0, MAX_TOOL_CALL_ID_LENGTH);
return toolId.slice(0, MAX_TOOL_CALL_ID_LENGTH)
}
return toolId;
return toolId
}
/**
@@ -68,17 +65,17 @@ function transformToolCallIdForNativeApi(
* @returns Array of OpenAI.Chat.ChatCompletionMessageParam objects
*/
export function convertToOpenAiMessages(
anthropicMessages: Anthropic.Messages.MessageParam[],
anthropicMessages: Omit<ClineStorageMessage, "modelInfo">[],
provider?: ApiProvider,
): OpenAI.Chat.ChatCompletionMessageParam[] {
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [];
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = []
for (const anthropicMessage of anthropicMessages) {
if (typeof anthropicMessage.content === "string") {
openAiMessages.push({
role: anthropicMessage.role,
content: anthropicMessage.content,
});
})
} else {
// image_url.url is base64 encoded image data
// ensure it contains the content-type of the image: data:image/png;base64,
@@ -89,56 +86,52 @@ export function convertToOpenAiMessages(
{ role: "tool", tool_call_id: "", content: ""}
*/
if (anthropicMessage.role === "user") {
const { nonToolMessages, toolMessages } =
anthropicMessage.content.reduce<{
nonToolMessages: (ClineTextContentBlock | ClineImageContentBlock)[];
toolMessages: ClineUserToolResultContentBlock[];
}>(
(acc, part) => {
if (part.type === "tool_result") {
acc.toolMessages.push(part);
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part);
} // user cannot send tool_use messages
return acc;
},
{ nonToolMessages: [], toolMessages: [] },
);
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (ClineTextContentBlock | ClineImageContentBlock)[]
toolMessages: ClineUserToolResultContentBlock[]
}>(
(acc, part) => {
if (part.type === "tool_result") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
} // user cannot send tool_use messages
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
// Process tool result messages FIRST since they must follow the tool use messages
const toolResultImages: ClineImageContentBlock[] = [];
const toolResultImages: ClineImageContentBlock[] = []
toolMessages.forEach((toolMessage) => {
// The Anthropic SDK allows tool results to be a string or an array of text and image blocks, enabling rich and structured content. In contrast, the OpenAI SDK only supports tool results as a single string, so we map the Anthropic tool result parts into one concatenated string to maintain compatibility.
let content: string;
let content: string
if (typeof toolMessage.content === "string") {
content = toolMessage.content;
content = toolMessage.content
} else if (Array.isArray(toolMessage.content)) {
content =
toolMessage.content
?.map((part) => {
if (part.type === "image") {
toolResultImages.push(part);
return "(see following user message for image)";
toolResultImages.push(part)
return "(see following user message for image)"
}
return part.text;
return part.text
})
.join("\n") ?? "";
.join("\n") ?? ""
} else {
// Handle undefined content
content = "";
content = ""
}
openAiMessages.push({
role: "tool",
// The tool_call_id must match the id used in the assistant's tool_calls array.
// Use the same transformation logic as tool_calls to ensure IDs match.
tool_call_id: transformToolCallIdForNativeApi(
toolMessage.tool_use_id,
provider,
),
tool_call_id: transformToolCallIdForNativeApi(toolMessage.tool_use_id, provider),
content: content,
});
});
})
})
// If tool results contain images, send as a separate user message
// I ran into an issue where if I gave feedback for one of many tool uses, the request would fail.
@@ -151,9 +144,9 @@ export function convertToOpenAiMessages(
role: "user",
content: toolResultImages.map((part) => ({
type: "image_url",
image_url: { url: getImageDataUrl(part.source) },
image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` },
})),
});
})
}
// Process non-tool messages
@@ -165,117 +158,106 @@ export function convertToOpenAiMessages(
return {
type: "image_url",
image_url: {
url: getImageDataUrl(part.source),
url: `data:${part.source.media_type};base64,${part.source.data}`,
},
};
}
}
return { type: "text", text: part.text };
return { type: "text", text: part.text }
}),
});
})
}
} else if (anthropicMessage.role === "assistant") {
const { nonToolMessages, toolMessages } =
anthropicMessage.content.reduce<{
nonToolMessages: (
| ClineTextContentBlock
| ClineImageContentBlock
| ClineAssistantThinkingBlock
| ClineAssistantRedactedThinkingBlock
)[];
toolMessages: ClineAssistantToolUseBlock[];
}>(
(acc, part) => {
if (part.type === "tool_use") {
acc.toolMessages.push(part);
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part);
} // assistant cannot send tool_result messages
return acc;
},
{ nonToolMessages: [], toolMessages: [] },
);
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (
| ClineTextContentBlock
| ClineImageContentBlock
| ClineAssistantThinkingBlock
| ClineAssistantRedactedThinkingBlock
)[]
toolMessages: ClineAssistantToolUseBlock[]
}>(
(acc, part) => {
if (part.type === "tool_use") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
} // assistant cannot send tool_result messages
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
// Process non-tool messages
let content: string | undefined;
const reasoningDetails: any[] = [];
const thinkingBlock = [];
let content: string | undefined
const reasoningDetails: any[] = []
const thinkingBlock = []
if (nonToolMessages.length > 0) {
nonToolMessages.forEach((part) => {
const anyPart = part as any;
const anyPart = part as any
if (part.type === "text" && anyPart.reasoning_details) {
if (Array.isArray(anyPart.reasoning_details)) {
reasoningDetails.push(...anyPart.reasoning_details);
reasoningDetails.push(...anyPart.reasoning_details)
} else {
reasoningDetails.push(anyPart.reasoning_details);
reasoningDetails.push(anyPart.reasoning_details)
}
}
if (part.type === "thinking" && part.thinking) {
// Reasoning details should have been moved to the text block
thinkingBlock.push(part);
thinkingBlock.push(part)
}
});
})
content = nonToolMessages
.map((part) => {
if (part.type === "text" && part.text) {
return part.text;
return part.text
}
return "";
return ""
})
.join("\n");
.join("\n")
}
// Process tool use messages
const tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] =
toolMessages.map((toolMessage) => {
const toolDetails = toolMessage.reasoning_details;
const toolId = toolMessage.id;
if (toolDetails) {
if (Array.isArray(toolDetails)) {
// For Gemini: reasoning details must be linkable back to the tool call.
// Sometimes OpenRouter/Gemini returns entries without `id`; those poison the next request.
// Keep only entries with an id matching the tool call id.
// See: https://github.com/cline/cline/issues/8214
const validDetails = toolDetails.filter(
(detail: any) => detail?.id === toolId,
);
if (validDetails.length > 0) {
reasoningDetails.push(...validDetails);
}
} else {
// Single reasoning detail - only include if it has matching id
const detail = toolDetails as any;
if (detail?.id === toolId) {
reasoningDetails.push(toolDetails);
}
const tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] = toolMessages.map((toolMessage) => {
const toolDetails = toolMessage.reasoning_details
const toolId = toolMessage.id
if (toolDetails) {
if (Array.isArray(toolDetails)) {
// For Gemini: reasoning details must be linkable back to the tool call.
// Sometimes OpenRouter/Gemini returns entries without `id`; those poison the next request.
// Keep only entries with an id matching the tool call id.
// See: https://github.com/cline/cline/issues/8214
const validDetails = toolDetails.filter((detail: any) => detail?.id === toolId)
if (validDetails.length > 0) {
reasoningDetails.push(...validDetails)
}
} else {
// Single reasoning detail - only include if it has matching id
const detail = toolDetails as any
if (detail?.id === toolId) {
reasoningDetails.push(toolDetails)
}
}
}
return {
// Use the same transformation as tool_call_id to ensure IDs match
id: transformToolCallIdForNativeApi(toolId, provider),
type: "function",
function: {
name: toolMessage.name,
// json string
arguments: JSON.stringify(toolMessage.input),
},
};
});
return {
// Use the same transformation as tool_call_id to ensure IDs match
id: transformToolCallIdForNativeApi(toolId, provider),
type: "function",
function: {
name: toolMessage.name,
// json string
arguments: JSON.stringify(toolMessage.input),
},
}
})
// Set content to blank when tool_calls are present but content has no text, per OpenAI API spec
const hasToolCalls = tool_calls.length > 0;
const hasMeaningfulContent =
content !== undefined && content.trim() !== "";
const finalContent = hasMeaningfulContent
? content
: hasToolCalls
? null
: undefined;
const hasToolCalls = tool_calls.length > 0
const hasMeaningfulContent = content !== undefined && content.trim() !== ""
const finalContent = hasMeaningfulContent ? content : hasToolCalls ? null : undefined
const consolidatedReasoningDetails =
reasoningDetails.length > 0
? consolidateReasoningDetails(reasoningDetails as any)
: [];
reasoningDetails.length > 0 ? consolidateReasoningDetails(reasoningDetails as any) : []
openAiMessages.push({
role: "assistant",
@@ -284,91 +266,86 @@ export function convertToOpenAiMessages(
tool_calls: tool_calls?.length > 0 ? tool_calls : undefined,
// Only include reasoning_details when non-empty; sending [] can trigger provider validation issues.
// @ts-expect-error
reasoning_details:
consolidatedReasoningDetails.length > 0
? consolidatedReasoningDetails
: undefined,
});
reasoning_details: consolidatedReasoningDetails.length > 0 ? consolidatedReasoningDetails : undefined,
})
}
}
}
return openAiMessages;
return openAiMessages
}
// Type for OpenRouter's reasoning detail elements
// https://openrouter.ai/docs/use-cases/reasoning-tokens#streaming-response
type ReasoningDetail = {
// https://openrouter.ai/docs/use-cases/reasoning-tokens#reasoning-detail-types
type: string; // "reasoning.summary" | "reasoning.encrypted" | "reasoning.text"
text?: string;
data?: string; // Encrypted reasoning data
signature?: string | null;
id?: string | null; // Unique identifier for the reasoning detail
type: string // "reasoning.summary" | "reasoning.encrypted" | "reasoning.text"
text?: string
data?: string // Encrypted reasoning data
signature?: string | null
id?: string | null // Unique identifier for the reasoning detail
/*
The format of the reasoning detail, with possible values:
"unknown" - Format is not specified
"openai-responses-v1" - OpenAI responses format version 1
"anthropic-claude-v1" - Anthropic Claude format version 1 (default)
*/
format: string; //"unknown" | "openai-responses-v1" | "anthropic-claude-v1" | "xai-responses-v1"
index?: number; // Sequential index of the reasoning detail
};
format: string //"unknown" | "openai-responses-v1" | "anthropic-claude-v1" | "xai-responses-v1"
index?: number // Sequential index of the reasoning detail
}
// Helper function to convert reasoning_details array to the format OpenRouter API expects
// Takes an array of reasoning detail objects and consolidates them by index
function consolidateReasoningDetails(
reasoningDetails: ReasoningDetail[],
): ReasoningDetail[] {
function consolidateReasoningDetails(reasoningDetails: ReasoningDetail[]): ReasoningDetail[] {
if (!reasoningDetails || reasoningDetails.length === 0) {
return [];
return []
}
// Group by index
const groupedByIndex = new Map<number, ReasoningDetail[]>();
const groupedByIndex = new Map<number, ReasoningDetail[]>()
for (const detail of reasoningDetails) {
// Drop corrupted encrypted reasoning blocks that would otherwise trigger:
// "Invalid input: expected string, received undefined" for reasoning_details.*.data
// See: https://github.com/cline/cline/issues/8214
if (detail.type === "reasoning.encrypted" && !detail.data) continue;
if (detail.type === "reasoning.encrypted" && !detail.data) continue
const index = detail.index ?? 0;
const index = detail.index ?? 0
if (!groupedByIndex.has(index)) {
groupedByIndex.set(index, []);
groupedByIndex.set(index, [])
}
groupedByIndex.get(index)!.push(detail);
groupedByIndex.get(index)!.push(detail)
}
// Consolidate each group
const consolidated: ReasoningDetail[] = [];
const consolidated: ReasoningDetail[] = []
for (const [index, details] of groupedByIndex.entries()) {
// Concatenate all text parts
let concatenatedText = "";
let signature: string | undefined;
let id: string | undefined;
let format = "unknown";
let type = "reasoning.text";
let concatenatedText = ""
let signature: string | undefined
let id: string | undefined
let format = "unknown"
let type = "reasoning.text"
for (const detail of details) {
if (detail.text) {
concatenatedText += detail.text;
concatenatedText += detail.text
}
// Keep the signature from the last item that has one
if (detail.signature) {
signature = detail.signature;
signature = detail.signature
}
// Keep the id from the last item that has one
if (detail.id) {
id = detail.id;
id = detail.id
}
// Keep format and type from any item (they should all be the same)
if (detail.format) {
format = detail.format;
format = detail.format
}
if (detail.type) {
type = detail.type;
type = detail.type
}
}
@@ -381,12 +358,12 @@ function consolidateReasoningDetails(
id: id,
format: format,
index: index,
};
consolidated.push(consolidatedEntry);
}
consolidated.push(consolidatedEntry)
}
// For encrypted chunks (data), only keep the last one
let lastDataEntry: ReasoningDetail | undefined;
let lastDataEntry: ReasoningDetail | undefined
for (const detail of details) {
if (detail.data) {
lastDataEntry = {
@@ -396,25 +373,23 @@ function consolidateReasoningDetails(
id: detail.id,
format: detail.format,
index: index,
};
}
}
}
if (lastDataEntry) {
consolidated.push(lastDataEntry);
consolidated.push(lastDataEntry)
}
}
return consolidated;
return consolidated
}
// Unique name to use to filter out tool call that cannot be parsed correctly
const UNIQUE_ERROR_TOOL_NAME = "_cline_error_unknown_function_";
const UNIQUE_ERROR_TOOL_NAME = "_cline_error_unknown_function_"
// Convert OpenAI response to Anthropic format
export function convertToAnthropicMessage(
completion: OpenAI.Chat.Completions.ChatCompletion,
): Anthropic.Messages.Message {
const openAiMessage = completion.choices[0].message;
export function convertToAnthropicMessage(completion: OpenAI.Chat.Completions.ChatCompletion): Anthropic.Messages.Message {
const openAiMessage = completion.choices[0].message
const anthropicMessage: Anthropic.Messages.Message = {
id: completion.id,
type: "message",
@@ -430,14 +405,14 @@ export function convertToAnthropicMessage(
stop_reason: (() => {
switch (completion.choices[0].finish_reason) {
case "stop":
return "end_turn";
return "end_turn"
case "length":
return "max_tokens";
return "max_tokens"
case "tool_calls":
return "tool_use";
return "tool_use"
case "content_filter": // Anthropic doesn't have an exact equivalent
default:
return null;
return null
}
})(),
stop_sequence: null, // which custom stop_sequence was generated, if any (not applicable if you don't use stop_sequence)
@@ -446,40 +421,37 @@ export function convertToAnthropicMessage(
output_tokens: completion.usage?.completion_tokens || 0,
cache_creation_input_tokens: null,
cache_read_input_tokens: null,
server_tool_use: null,
},
};
}
try {
if (openAiMessage?.tool_calls?.length) {
const functionCalls = openAiMessage.tool_calls.filter(
(tc: any) => tc?.type === "function" && tc.function,
);
const functionCalls = openAiMessage.tool_calls.filter((tc: any) => tc?.type === "function" && tc.function)
if (functionCalls.length > 0) {
anthropicMessage.content.push(
...functionCalls.map((toolCall: any): Anthropic.ToolUseBlock => {
let parsedInput = {};
let parsedInput = {}
try {
parsedInput = JSON.parse(toolCall.function?.arguments || "{}");
parsedInput = JSON.parse(toolCall.function?.arguments || "{}")
} catch (error) {
Logger.error("Failed to parse tool arguments:", error);
Logger.error("Failed to parse tool arguments:", error)
}
return {
type: "tool_use",
id: toolCall.id,
name: toolCall.function?.name || UNIQUE_ERROR_TOOL_NAME,
input: parsedInput,
};
}
}),
);
)
}
return anthropicMessage;
return anthropicMessage
}
} catch (error) {
Logger.error("Error converting OpenAI message to Anthropic format:", error);
Logger.error("Error converting OpenAI message to Anthropic format:", error)
}
return anthropicMessage;
return anthropicMessage
}
/**
@@ -498,47 +470,43 @@ export function sanitizeGeminiMessages(
modelId: string,
): OpenAI.Chat.ChatCompletionMessageParam[] {
if (!modelId.includes("gemini")) {
return messages;
return messages
}
const droppedToolCallIds = new Set<string>();
const sanitized: OpenAI.Chat.ChatCompletionMessageParam[] = [];
const droppedToolCallIds = new Set<string>()
const sanitized: OpenAI.Chat.ChatCompletionMessageParam[] = []
for (const msg of messages) {
if (msg.role === "assistant") {
const anyMsg = msg as any;
const toolCalls = anyMsg.tool_calls;
const anyMsg = msg as any
const toolCalls = anyMsg.tool_calls
if (Array.isArray(toolCalls) && toolCalls.length > 0) {
const reasoningDetails = anyMsg.reasoning_details;
const hasReasoningDetails =
Array.isArray(reasoningDetails) && reasoningDetails.length > 0;
const reasoningDetails = anyMsg.reasoning_details
const hasReasoningDetails = Array.isArray(reasoningDetails) && reasoningDetails.length > 0
if (!hasReasoningDetails) {
for (const tc of toolCalls) {
if (tc?.id) {
droppedToolCallIds.add(tc.id);
droppedToolCallIds.add(tc.id)
}
}
// Keep any textual content, but drop the tool_calls themselves.
if (anyMsg.content) {
sanitized.push({
role: "assistant",
content: anyMsg.content,
} as any);
sanitized.push({ role: "assistant", content: anyMsg.content } as any)
}
continue;
continue
}
}
}
if (msg.role === "tool") {
const anyMsg = msg as any;
const anyMsg = msg as any
if (anyMsg.tool_call_id && droppedToolCallIds.has(anyMsg.tool_call_id)) {
continue;
continue
}
}
sanitized.push(msg);
sanitized.push(msg)
}
return sanitized;
return sanitized
}
@@ -1,13 +1,5 @@
import type {
ResponseInput,
ResponseInputMessageContentList,
ResponseReasoningItem,
} from "openai/resources/responses/responses";
import {
type ClineStorageMessage,
getBase64ImageSource,
getImageDataUrl,
} from "@/shared/messages/content";
import { ResponseInput, ResponseInputMessageContentList, ResponseReasoningItem } from "openai/resources/responses/responses"
import { ClineStorageMessage } from "@/shared/messages/content"
/**
* Converts an array of ClineStorageMessage objects (extension of Anthropic format) to a ResponseInput array to use with OpenAI's Responses API.
@@ -83,69 +75,56 @@ export function convertToOpenAIResponsesInput(
_messages: ClineStorageMessage[],
options?: { usePreviousResponseId?: boolean },
): {
input: ResponseInput;
previousResponseId?: string;
input: ResponseInput
previousResponseId?: string
} {
// Chain from the latest stored Responses API assistant message when available.
// When chaining, only send new items after that assistant turn.
let previousResponseId: string | undefined;
let messages = _messages;
let previousResponseId: string | undefined
let messages = _messages
if (options?.usePreviousResponseId) {
for (let i = _messages.length - 1; i >= 0; i--) {
const msg = _messages[i];
const msg = _messages[i]
// Must be less than 24 hours old to be considered for chaining as the previous Id is only valid for 24 hours.
// Set to 23 hours to account for any potential delays in processing.
const isLessThan23HoursOld = msg.ts
? Date.now() - msg.ts < 23 * 60 * 60 * 1000
: false;
const isLessThan23HoursOld = msg.ts ? Date.now() - msg.ts < 23 * 60 * 60 * 1000 : false
if (msg.role === "assistant" && msg.id && isLessThan23HoursOld) {
previousResponseId = msg.id;
messages = _messages.slice(i + 1);
break;
previousResponseId = msg.id
messages = _messages.slice(i + 1)
break
}
}
}
const allItems: any[] = [];
const toolUseIdToCallId = new Map<string, string>();
const allItems: any[] = []
const toolUseIdToCallId = new Map<string, string>()
for (const m of messages) {
if (typeof m.content === "string") {
allItems.push({
role: m.role,
content: [{ type: "input_text", text: m.content }],
});
continue;
allItems.push({ role: m.role, content: [{ type: "input_text", text: m.content }] })
continue
}
if (m.role === "assistant") {
// For assistant messages, we must ensure reasoning items are IMMEDIATELY followed
// by their corresponding message or function_call. Process the entire assistant
// turn and ensure proper pairing.
const assistantItems: any[] = [];
const assistantItems: any[] = []
for (const part of m.content) {
switch (part.type) {
case "thinking": {
case "thinking":
// Only include reasoning item if it has actual content (thinking text or summary)
// Empty reasoning items cause API errors: "Item 'rs_...' of type 'reasoning' was provided without its required following item"
const hasThinkingContent =
part.thinking && part.thinking.trim().length > 0;
const hasSummaryContent =
part.summary &&
Array.isArray(part.summary) &&
part.summary.length > 0;
const hasThinkingContent = part.thinking && part.thinking.trim().length > 0
const hasSummaryContent = part.summary && Array.isArray(part.summary) && part.summary.length > 0
if (
part.call_id &&
part.call_id.length > 0 &&
(hasThinkingContent || hasSummaryContent)
) {
if (part.call_id && part.call_id.length > 0 && (hasThinkingContent || hasSummaryContent)) {
// Use summary if available, otherwise use thinking text
let summary: any[] = [];
let summary: any[] = []
if (hasSummaryContent) {
// part.summary is already in the correct format from OpenAI Responses API
summary = part.summary as any[];
summary = part.summary as any[]
} else if (hasThinkingContent) {
// Convert thinking text to summary format
summary = [
@@ -153,17 +132,16 @@ export function convertToOpenAIResponsesInput(
type: "summary_text",
text: part.thinking,
},
];
]
}
assistantItems.push({
id: part.call_id,
type: "reasoning",
summary,
} as ResponseReasoningItem);
} as ResponseReasoningItem)
}
break;
}
break
case "redacted_thinking":
// Include reasoning item with encrypted content if it has a call_id
// Even if data is missing, we need to maintain the reasoning-function_call pairing
@@ -172,115 +150,100 @@ export function convertToOpenAIResponsesInput(
id: part.call_id,
type: "reasoning",
summary: [],
};
}
// Only include encrypted_content if data exists
if (part.data) {
reasoningItem.encrypted_content = part.data;
reasoningItem.encrypted_content = part.data
}
assistantItems.push(reasoningItem as ResponseReasoningItem);
assistantItems.push(reasoningItem as ResponseReasoningItem)
}
break;
case "text": {
break
case "text":
// Message ID goes at the message level, not in the content
// The reasoning item and message can have different IDs - they just need to be adjacent
const messageItem: any = {
type: "message",
role: "assistant",
content: [{ type: "output_text", text: part.text }],
};
}
// Set message-level id if available
if (part.call_id) {
messageItem.id = part.call_id;
messageItem.id = part.call_id
}
assistantItems.push(messageItem);
break;
}
case "image": {
assistantItems.push(messageItem)
break
case "image":
// Message ID goes at the message level, not in the content
const imageItem: any = {
type: "message",
role: "assistant",
content: [
{
type: "output_text",
text: `[image:${getBase64ImageSource(part.source).mediaType}]`,
},
],
};
content: [{ type: "output_text", text: `[image:${part.source.media_type}]` }],
}
// Set message-level id if available (though images typically don't have call_id)
if (part.call_id) {
imageItem.id = part.call_id;
imageItem.id = part.call_id
}
assistantItems.push(imageItem);
break;
}
assistantItems.push(imageItem)
break
case "tool_use": {
// Function calls use call_id, not related to reasoning item ID
const call_id = part.call_id || part.id;
const call_id = part.call_id || part.id
if (part.call_id) {
toolUseIdToCallId.set(part.id, part.call_id);
toolUseIdToCallId.set(part.id, part.call_id)
}
assistantItems.push({
type: "function_call",
call_id,
// MAX 53 characters for OpenAI Responses API tool IDs
id: !part.id.startsWith("fc_")
? `fc_${part.id.slice(0, 50)}`
: part.id,
id: !part.id.startsWith("fc_") ? `fc_${part.id.slice(0, 50)}` : part.id,
name: part.name,
arguments: JSON.stringify(part.input ?? {}),
});
break;
})
break
}
}
}
allItems.push(...assistantItems);
allItems.push(...assistantItems)
} else {
// User messages - collect all content
const messageContent: ResponseInputMessageContentList = [];
const messageContent: ResponseInputMessageContentList = []
for (const part of m.content) {
switch (part.type) {
case "text":
messageContent.push({ type: "input_text", text: part.text });
break;
messageContent.push({ type: "input_text", text: part.text })
break
case "image":
messageContent.push({
type: "input_image",
detail: "auto",
image_url: getImageDataUrl(part.source),
});
break;
image_url: `data:${part.source.media_type};base64,${part.source.data}`,
})
break
case "tool_result": {
// Flush any pending message content before adding tool result
if (messageContent.length > 0) {
allItems.push({ role: m.role, content: [...messageContent] });
messageContent.length = 0;
allItems.push({ role: m.role, content: [...messageContent] })
messageContent.length = 0
}
const call_id =
part.call_id ||
toolUseIdToCallId.get(part.tool_use_id) ||
part.tool_use_id;
const call_id = part.call_id || toolUseIdToCallId.get(part.tool_use_id) || part.tool_use_id
allItems.push({
type: "function_call_output",
call_id,
output:
typeof part.content === "string"
? part.content
: JSON.stringify(part.content),
});
break;
output: typeof part.content === "string" ? part.content : JSON.stringify(part.content),
})
break
}
}
}
// Flush any remaining user message content
if (messageContent.length > 0) {
allItems.push({ role: m.role, content: [...messageContent] });
allItems.push({ role: m.role, content: [...messageContent] })
}
}
}
return { input: allItems, previousResponseId };
return { input: allItems, previousResponseId }
}
@@ -3,14 +3,12 @@ import {
CLAUDE_SONNET_1M_SUFFIX,
ModelInfo,
OPENROUTER_PROVIDER_PREFERENCES,
openRouterClaudeFable51mModelId,
openRouterClaudeOpus461mModelId,
openRouterClaudeOpus471mModelId,
openRouterClaudeOpus481mModelId,
openRouterClaudeSonnet41mModelId,
openRouterClaudeSonnet451mModelId,
openRouterClaudeSonnet461mModelId,
openRouterClaudeSonnet51mModelId,
} from "@shared/api"
import { normalizeOpenaiReasoningEffort } from "@shared/storage/types"
import { isClaudeOpusAdaptiveThinkingModel, resolveClaudeOpusAdaptiveThinking } from "@shared/utils/reasoning-support"
@@ -63,11 +61,9 @@ export async function createOpenRouterStream(
model.id === openRouterClaudeSonnet41mModelId ||
model.id === openRouterClaudeSonnet451mModelId ||
model.id === openRouterClaudeSonnet461mModelId ||
model.id === openRouterClaudeSonnet51mModelId ||
model.id === openRouterClaudeOpus461mModelId ||
model.id === openRouterClaudeOpus471mModelId ||
model.id === openRouterClaudeOpus481mModelId ||
model.id === openRouterClaudeFable51mModelId
model.id === openRouterClaudeOpus481mModelId
if (isClaude1m) {
// remove the custom :1m suffix, to create the model id openrouter API expects
model.id = model.id.slice(0, -CLAUDE_SONNET_1M_SUFFIX.length)
@@ -149,8 +145,6 @@ export async function createOpenRouterStream(
switch (model.id) {
case "anthropic/claude-haiku-4.5":
case "anthropic/claude-4.5-haiku":
case "anthropic/claude-sonnet-5":
case "anthropic/claude-5-sonnet":
case "anthropic/claude-sonnet-4.6":
case "anthropic/claude-4.6-sonnet":
case "anthropic/claude-sonnet-4.5":
+88 -114
View File
@@ -1,17 +1,13 @@
import type { Anthropic } from "@anthropic-ai/sdk";
import type OpenAI from "openai";
import {
type ClineAssistantThinkingBlock,
type ClineStorageMessage,
getImageDataUrl,
} from "@/shared/messages/content";
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { ClineAssistantThinkingBlock, ClineStorageMessage } from "@/shared/messages/content"
/**
* DeepSeek Reasoner message format with reasoning_content support.
*/
export type DeepSeekReasonerMessage = OpenAI.Chat.ChatCompletionMessageParam & {
reasoning_content?: string;
};
reasoning_content?: string
}
/**
* Adds reasoning_content to OpenAI messages for DeepSeek Reasoner.
@@ -25,45 +21,43 @@ export function addReasoningContent(
// Find last user message index (start of current turn)
// If no user message exists (lastUserIndex = -1), all messages are in the "current turn",
// so reasoning_content will be added to all assistant messages. This is intentional.
let lastUserIndex = -1;
let lastUserIndex = -1
for (let i = openAiMessages.length - 1; i >= 0; i--) {
if (openAiMessages[i].role === "user") {
lastUserIndex = i;
break;
lastUserIndex = i
break
}
}
// Extract thinking content from original messages, keyed by assistant index
const thinkingByIndex = new Map<number, string>();
let assistantIdx = 0;
const thinkingByIndex = new Map<number, string>()
let assistantIdx = 0
for (const msg of originalMessages) {
if (msg.role === "assistant") {
if (Array.isArray(msg.content)) {
const thinking = msg.content
.filter(
(p): p is ClineAssistantThinkingBlock => p.type === "thinking",
)
.filter((p): p is ClineAssistantThinkingBlock => p.type === "thinking")
.map((p) => p.thinking)
.join("\n");
.join("\n")
if (thinking) {
thinkingByIndex.set(assistantIdx, thinking);
thinkingByIndex.set(assistantIdx, thinking)
}
}
assistantIdx++;
assistantIdx++
}
}
// Add reasoning_content only to assistant messages in current turn
let aiIdx = 0;
let aiIdx = 0
return openAiMessages.map((msg, i): DeepSeekReasonerMessage => {
if (msg.role === "assistant") {
const thinking = thinkingByIndex.get(aiIdx++);
const thinking = thinkingByIndex.get(aiIdx++)
if (thinking && i >= lastUserIndex) {
return { ...msg, reasoning_content: thinking };
return { ...msg, reasoning_content: thinking }
}
}
return msg;
});
return msg
})
}
/**
@@ -74,104 +68,84 @@ export function addReasoningContent(
* @param messages Array of Anthropic messages
* @returns Array of OpenAI messages where consecutive messages with the same role are merged together
*/
export function convertToR1Format(
messages: Anthropic.Messages.MessageParam[],
): OpenAI.Chat.ChatCompletionMessageParam[] {
return messages.reduce<OpenAI.Chat.ChatCompletionMessageParam[]>(
(merged, message) => {
const lastMessage = merged[merged.length - 1];
let messageContent:
| string
| (
| OpenAI.Chat.ChatCompletionContentPartText
| OpenAI.Chat.ChatCompletionContentPartImage
)[] = "";
let hasImages = false;
export function convertToR1Format(messages: Anthropic.Messages.MessageParam[]): OpenAI.Chat.ChatCompletionMessageParam[] {
return messages.reduce<OpenAI.Chat.ChatCompletionMessageParam[]>((merged, message) => {
const lastMessage = merged[merged.length - 1]
let messageContent: string | (OpenAI.Chat.ChatCompletionContentPartText | OpenAI.Chat.ChatCompletionContentPartImage)[] =
""
let hasImages = false
if (Array.isArray(message.content)) {
const textParts: string[] = [];
const imageParts: OpenAI.Chat.ChatCompletionContentPartImage[] = [];
if (Array.isArray(message.content)) {
const textParts: string[] = []
const imageParts: OpenAI.Chat.ChatCompletionContentPartImage[] = []
message.content.forEach((part) => {
if (part.type === "text") {
textParts.push(part.text);
}
if (part.type === "image") {
hasImages = true;
imageParts.push({
type: "image_url",
image_url: { url: getImageDataUrl(part.source) },
});
}
});
if (hasImages) {
const parts: (
| OpenAI.Chat.ChatCompletionContentPartText
| OpenAI.Chat.ChatCompletionContentPartImage
)[] = [];
if (textParts.length > 0) {
parts.push({ type: "text", text: textParts.join("\n") });
}
parts.push(...imageParts);
messageContent = parts;
} else {
messageContent = textParts.join("\n");
message.content.forEach((part) => {
if (part.type === "text") {
textParts.push(part.text)
}
if (part.type === "image") {
hasImages = true
imageParts.push({
type: "image_url",
image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` },
})
}
})
if (hasImages) {
const parts: (OpenAI.Chat.ChatCompletionContentPartText | OpenAI.Chat.ChatCompletionContentPartImage)[] = []
if (textParts.length > 0) {
parts.push({ type: "text", text: textParts.join("\n") })
}
parts.push(...imageParts)
messageContent = parts
} else {
messageContent = message.content;
messageContent = textParts.join("\n")
}
} else {
messageContent = message.content
}
// If the last message has the same role, merge the content
if (lastMessage?.role === message.role) {
if (
typeof lastMessage.content === "string" &&
typeof messageContent === "string"
) {
lastMessage.content += `\n${messageContent}`;
} else {
const lastContent = Array.isArray(lastMessage.content)
? lastMessage.content
: [{ type: "text" as const, text: lastMessage.content || "" }];
const newContent = Array.isArray(messageContent)
? messageContent
: [{ type: "text" as const, text: messageContent }];
if (message.role === "assistant") {
const mergedContent = [
...lastContent,
...newContent,
] as OpenAI.Chat.ChatCompletionAssistantMessageParam["content"];
lastMessage.content = mergedContent;
} else {
const mergedContent = [
...lastContent,
...newContent,
] as OpenAI.Chat.ChatCompletionUserMessageParam["content"];
lastMessage.content = mergedContent;
}
}
// If the last message has the same role, merge the content
if (lastMessage?.role === message.role) {
if (typeof lastMessage.content === "string" && typeof messageContent === "string") {
lastMessage.content += `\n${messageContent}`
} else {
// Adds new message with the correct type based on role
const lastContent = Array.isArray(lastMessage.content)
? lastMessage.content
: [{ type: "text" as const, text: lastMessage.content || "" }]
const newContent = Array.isArray(messageContent)
? messageContent
: [{ type: "text" as const, text: messageContent }]
if (message.role === "assistant") {
const newMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam = {
role: "assistant",
content:
messageContent as OpenAI.Chat.ChatCompletionAssistantMessageParam["content"],
};
merged.push(newMessage);
const mergedContent = [
...lastContent,
...newContent,
] as OpenAI.Chat.ChatCompletionAssistantMessageParam["content"]
lastMessage.content = mergedContent
} else {
const newMessage: OpenAI.Chat.ChatCompletionUserMessageParam = {
role: "user",
content:
messageContent as OpenAI.Chat.ChatCompletionUserMessageParam["content"],
};
merged.push(newMessage);
const mergedContent = [...lastContent, ...newContent] as OpenAI.Chat.ChatCompletionUserMessageParam["content"]
lastMessage.content = mergedContent
}
}
return merged;
},
[],
);
} else {
// Adds new message with the correct type based on role
if (message.role === "assistant") {
const newMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam = {
role: "assistant",
content: messageContent as OpenAI.Chat.ChatCompletionAssistantMessageParam["content"],
}
merged.push(newMessage)
} else {
const newMessage: OpenAI.Chat.ChatCompletionUserMessageParam = {
role: "user",
content: messageContent as OpenAI.Chat.ChatCompletionUserMessageParam["content"],
}
merged.push(newMessage)
}
}
return merged
}, [])
}
@@ -2,14 +2,12 @@ import { Anthropic } from "@anthropic-ai/sdk"
import {
CLAUDE_SONNET_1M_SUFFIX,
ModelInfo,
openRouterClaudeFable51mModelId,
openRouterClaudeOpus461mModelId,
openRouterClaudeOpus471mModelId,
openRouterClaudeOpus481mModelId,
openRouterClaudeSonnet41mModelId,
openRouterClaudeSonnet451mModelId,
openRouterClaudeSonnet461mModelId,
openRouterClaudeSonnet51mModelId,
} from "@shared/api"
import { normalizeOpenaiReasoningEffort } from "@shared/storage/types"
import { isClaudeOpusAdaptiveThinkingModel, resolveClaudeOpusAdaptiveThinking } from "@shared/utils/reasoning-support"
@@ -39,11 +37,9 @@ export async function createVercelAIGatewayStream(
model.id === openRouterClaudeSonnet41mModelId ||
model.id === openRouterClaudeSonnet451mModelId ||
model.id === openRouterClaudeSonnet461mModelId ||
model.id === openRouterClaudeSonnet51mModelId ||
model.id === openRouterClaudeOpus461mModelId ||
model.id === openRouterClaudeOpus471mModelId ||
model.id === openRouterClaudeOpus481mModelId ||
model.id === openRouterClaudeFable51mModelId
model.id === openRouterClaudeOpus481mModelId
if (isClaude1m) {
// remove the custom :1m suffix, to create the model id the API expects
model.id = model.id.slice(0, -CLAUDE_SONNET_1M_SUFFIX.length)
@@ -1,6 +1,6 @@
import type { Anthropic } from "@anthropic-ai/sdk";
import * as vscode from "vscode";
import { Logger } from "@/shared/services/Logger";
import { Anthropic } from "@anthropic-ai/sdk"
import * as vscode from "vscode"
import { Logger } from "@/shared/services/Logger"
/**
* Safely converts a value into a plain object.
@@ -8,31 +8,31 @@ import { Logger } from "@/shared/services/Logger";
export function asObjectSafe(value: any): object {
// Handle null/undefined
if (!value) {
return {};
return {}
}
try {
// Handle strings that might be JSON
if (typeof value === "string") {
return JSON.parse(value);
return JSON.parse(value)
}
// Handle pre-existing objects
if (typeof value === "object") {
return Object.assign({}, value);
return Object.assign({}, value)
}
return {};
return {}
} catch (error) {
Logger.warn("Cline <Language Model API>: Failed to parse object:", error);
return {};
Logger.warn("Cline <Language Model API>: Failed to parse object:", error)
return {}
}
}
export function convertToVsCodeLmMessages(
anthropicMessages: Anthropic.Messages.MessageParam[],
): vscode.LanguageModelChatMessage[] {
const vsCodeLmMessages: vscode.LanguageModelChatMessage[] = [];
const vsCodeLmMessages: vscode.LanguageModelChatMessage[] = []
for (const anthropicMessage of anthropicMessages) {
// Handle simple string messages
@@ -41,31 +41,27 @@ export function convertToVsCodeLmMessages(
anthropicMessage.role === "assistant"
? vscode.LanguageModelChatMessage.Assistant(anthropicMessage.content)
: vscode.LanguageModelChatMessage.User(anthropicMessage.content),
);
continue;
)
continue
}
// Handle complex message structures
switch (anthropicMessage.role) {
case "user": {
const { nonToolMessages, toolMessages } =
anthropicMessage.content.reduce<{
nonToolMessages: (
| Anthropic.TextBlockParam
| Anthropic.ImageBlockParam
)[];
toolMessages: Anthropic.ToolResultBlockParam[];
}>(
(acc, part) => {
if (part.type === "tool_result") {
acc.toolMessages.push(part);
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part);
}
return acc;
},
{ nonToolMessages: [], toolMessages: [] },
);
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
toolMessages: Anthropic.ToolResultBlockParam[]
}>(
(acc, part) => {
if (part.type === "tool_result") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
}
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
// Process tool messages first then non-tool messages
const contentParts = [
@@ -78,55 +74,46 @@ export function convertToVsCodeLmMessages(
: (toolMessage.content?.map((part) => {
if (part.type === "image") {
return new vscode.LanguageModelTextPart(
`[Image (${part.source?.type || "Unknown source-type"}): ${(part.source?.type === "base64" && part.source.media_type) || "unknown media-type"} not supported by VSCode LM API]`,
);
`[Image (${part.source?.type || "Unknown source-type"}): ${part.source?.media_type || "unknown media-type"} not supported by VSCode LM API]`,
)
}
return new vscode.LanguageModelTextPart(part.text);
}) ?? [new vscode.LanguageModelTextPart("")]);
return new vscode.LanguageModelTextPart(part.text)
}) ?? [new vscode.LanguageModelTextPart("")])
return new vscode.LanguageModelToolResultPart(
toolMessage.tool_use_id,
toolContentParts,
);
return new vscode.LanguageModelToolResultPart(toolMessage.tool_use_id, toolContentParts)
}),
// Convert non-tool messages to TextParts after tool messages
...nonToolMessages.map((part) => {
if (part.type === "image") {
return new vscode.LanguageModelTextPart(
`[Image (${part.source?.type || "Unknown source-type"}): ${(part.source?.type === "base64" && part.source.media_type) || "unknown media-type"} not supported by VSCode LM API]`,
);
`[Image (${part.source?.type || "Unknown source-type"}): ${part.source?.media_type || "unknown media-type"} not supported by VSCode LM API]`,
)
}
return new vscode.LanguageModelTextPart(part.text);
return new vscode.LanguageModelTextPart(part.text)
}),
];
]
// Add single user message with all content parts
vsCodeLmMessages.push(
vscode.LanguageModelChatMessage.User(contentParts),
);
break;
vsCodeLmMessages.push(vscode.LanguageModelChatMessage.User(contentParts))
break
}
case "assistant": {
const { nonToolMessages, toolMessages } =
anthropicMessage.content.reduce<{
nonToolMessages: (
| Anthropic.TextBlockParam
| Anthropic.ImageBlockParam
)[];
toolMessages: Anthropic.ToolUseBlockParam[];
}>(
(acc, part) => {
if (part.type === "tool_use") {
acc.toolMessages.push(part);
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part);
}
return acc;
},
{ nonToolMessages: [], toolMessages: [] },
);
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
toolMessages: Anthropic.ToolUseBlockParam[]
}>(
(acc, part) => {
if (part.type === "tool_use") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
}
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
// Process tool messages first then non-tool messages
const contentParts = [
@@ -143,24 +130,20 @@ export function convertToVsCodeLmMessages(
// Convert non-tool messages to TextParts after tool messages
...nonToolMessages.map((part) => {
if (part.type === "image") {
return new vscode.LanguageModelTextPart(
"[Image generation not supported by VSCode LM API]",
);
return new vscode.LanguageModelTextPart("[Image generation not supported by VSCode LM API]")
}
return new vscode.LanguageModelTextPart(part.text);
return new vscode.LanguageModelTextPart(part.text)
}),
];
]
// Add the assistant message to the list of messages
vsCodeLmMessages.push(
vscode.LanguageModelChatMessage.Assistant(contentParts),
);
break;
vsCodeLmMessages.push(vscode.LanguageModelChatMessage.Assistant(contentParts))
break
}
}
}
return vsCodeLmMessages;
return vsCodeLmMessages
}
export function convertToAnthropicRole(
@@ -168,22 +151,18 @@ export function convertToAnthropicRole(
): Anthropic.Messages.MessageParam["role"] | null {
switch (vsCodeLmMessageRole) {
case vscode.LanguageModelChatMessageRole.Assistant:
return "assistant";
return "assistant"
case vscode.LanguageModelChatMessageRole.User:
return "user";
return "user"
default:
return null;
return null
}
}
export function convertToAnthropicMessage(
vsCodeLmMessage: vscode.LanguageModelChatMessage,
): Anthropic.Messages.Message {
const anthropicRole = convertToAnthropicRole(vsCodeLmMessage.role);
export function convertToAnthropicMessage(vsCodeLmMessage: vscode.LanguageModelChatMessage): Anthropic.Messages.Message {
const anthropicRole = convertToAnthropicRole(vsCodeLmMessage.role)
if (anthropicRole !== "assistant") {
throw new Error(
"Cline <Language Model API>: Only assistant messages are supported.",
);
throw new Error("Cline <Language Model API>: Only assistant messages are supported.")
}
return {
@@ -198,7 +177,7 @@ export function convertToAnthropicMessage(
type: "text",
text: part.value,
citations: null,
};
}
}
if (part instanceof vscode.LanguageModelToolCallPart) {
@@ -207,10 +186,10 @@ export function convertToAnthropicMessage(
id: part.callId || crypto.randomUUID(),
name: part.name,
input: asObjectSafe(part.input),
};
}
}
return null;
return null
})
.filter((part): part is Anthropic.ContentBlock => part !== null),
stop_reason: null,
@@ -220,7 +199,6 @@ export function convertToAnthropicMessage(
output_tokens: 0,
cache_creation_input_tokens: null,
cache_read_input_tokens: null,
server_tool_use: null,
},
};
}
}
@@ -1,14 +1,14 @@
import * as yaml from "js-yaml";
import * as yaml from "js-yaml"
export type FrontmatterParseResult = {
data: Record<string, unknown>;
data: Record<string, unknown>
/**
* The markdown content after stripping the `--- frontmatter ---` block.
*
* Named `body` (rather than `content`) to make it clear this is the remaining
* document body and to keep this helper generic for multiple consumers.
*/
body: string;
body: string
/**
* True when the input contained a frontmatter block, even if parsing failed.
@@ -17,15 +17,15 @@ export type FrontmatterParseResult = {
* - "no frontmatter provided" (baseline behavior), vs
* - "frontmatter was provided" (may have semantic meaning in future consumers).
*/
hadFrontmatter: boolean;
hadFrontmatter: boolean
/**
* Present only when YAML frontmatter was detected but failed to parse.
*
* This helper is intentionally fail-open and does not log. Returning `parseError`
* lets each caller decide whether to log, surface diagnostics, etc.
*/
parseError?: string;
};
parseError?: string
}
/**
* Parse YAML frontmatter from markdown content.
@@ -35,32 +35,19 @@ export type FrontmatterParseResult = {
* - If no frontmatter exists, returns data={} and body=original markdown.
*/
export function parseYamlFrontmatter(markdown: string): FrontmatterParseResult {
if (markdown.charCodeAt(0) === 0xfeff) {
markdown = markdown.slice(1); // Remove BOM format if present
}
const frontmatterRegex = /^\s*---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
const match = markdown.match(frontmatterRegex);
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/
const match = markdown.match(frontmatterRegex)
if (!match) {
return { data: {}, body: markdown, hadFrontmatter: false };
return { data: {}, body: markdown, hadFrontmatter: false }
}
const [, yamlContent, body] = match;
const [, yamlContent, body] = match
try {
const data =
(yaml.load(yamlContent, { schema: yaml.JSON_SCHEMA }) as Record<
string,
unknown
>) || {};
return { data, body, hadFrontmatter: true };
const data = (yaml.load(yamlContent, { schema: yaml.JSON_SCHEMA }) as Record<string, unknown>) || {}
return { data, body, hadFrontmatter: true }
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
data: {},
body: markdown,
hadFrontmatter: true,
parseError: message,
};
const message = error instanceof Error ? error.message : String(error)
return { data: {}, body: markdown, hadFrontmatter: true, parseError: message }
}
}

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