Compare commits

..

113 Commits

Author SHA1 Message Date
Max Paulus 🥪 7867ab11ea strip maxTokens for openai-codex provider (client-side) 2026-06-23 14:46:32 -07:00
BarreiroT 6359430392 fix cline-pass options 2026-06-23 13:39:35 -03:00
Saoud Rizwan d31f03d3f9 fix(core): add OCA legacy reasoning effort (#11746) 2026-06-23 09:24:42 -07:00
Saoud Rizwan 763085a727 fix: wire up SAP provider (#11745)
* fix(vscode): wire SAP AI Core session config

* fix(vscode): remove redundant SAP base URL mapping
2026-06-23 02:39:42 -07:00
Dominic Cooney 6c9adcef96 fix(llms): restore provider-request capture wiring lost in SDK migration 2026-06-23 17:20:00 +09:00
Dominic Cooney 683b6d7f3e chore: regenerate bun.lock after rebase onto main 2026-06-23 17:14:20 +09:00
Dominic Cooney a1e776d8d0 fix(vscode): preserve OrgClinePass error UI through SDK rebase 2026-06-23 17:14:20 +09:00
Tomás Barreiro 5809b79f18 Identfy accounts for feature flag resolution (#11741)
* Identify accounts for Feature Flag resolution

* simply code
2026-06-23 17:05:54 +09:00
Max 1d0655157e if search result is undefined then don't crash the extension (#11733)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-06-23 17:05:54 +09:00
Max Paulus 🥪 7c9365fc56 remove gap between approve bar and input box 2026-06-23 17:05:54 +09:00
BarreiroT 793706ebfb Add log 2026-06-23 17:05:53 +09:00
BarreiroT 7c2dfdb71e Fix imports 2026-06-23 17:05:53 +09:00
Saoud Rizwan 2e962aeeda test(vscode): raise vitest testTimeout to 20s to fix import-cost flakes (#11729)
Several vitest suites lazily await import() their subject inside the first
test (so vi.mock factories apply first). That import pulls in heavy workspace
packages (@cline/core, @cline/llms, @cline/shared), and on loaded CI runners
the first test in a file intermittently exceeds the 5s default timeout and
fails the nightly (observed in catalog.test.ts, now resolveModelInfo.test.ts).
Set a global 20s testTimeout so import cost attributed to the first test does
not cause flakes.
2026-06-23 17:05:53 +09:00
Saoud Rizwan a5b85bc5cc test(vscode): add invalidateProviderListings to auth-service mock controllers (#11727)
#11720 (feature flag resolution on startup) added a
controller.invalidateProviderListings() call to AuthService.sendAuthStatusUpdate
but did not update the test's mock controllers, which only stubbed
postStateToWebview. The new call threw on the mocks, so the throw happened
before postStateToWebview ran (failing the 'polls feature flags' test) and
caused subscribeToAuthStatusUpdate to delete the handler in its catch block
(failing the 'removes subscription on cleanup' test). Add the now-required
invalidateProviderListings stub to the mock controllers.
2026-06-23 17:05:53 +09:00
BarreiroT f7742664ca Log feature flags 2026-06-23 17:05:53 +09:00
Saoud Rizwan 207b702054 fix(sdk): repair exposed provider auth routing (#11721)
* fix(sdk): repair exposed provider auth routing

* fix(sdk): use accessible ZAI coding plan default

* fix(sdk): use live Poolside model default

* docs(vscode): explain SDK provider key fallback
2026-06-23 17:05:53 +09:00
Tomás Barreiro f1a2923c4e Fix ModelAutocomplete selection (#11718) 2026-06-23 17:05:53 +09:00
Tomás Barreiro 17e95deb6a Fix Feature Flag resolution on startup (#11720)
* Fix Feature Flag resolution on startup

* remove irrelevant test
2026-06-23 17:05:53 +09:00
Saoud Rizwan 76b29b54f6 test(vscode): use toBe instead of toMatchObject in proto conversion test (#11712)
api-configuration-conversion.test.ts is picked up by both the vitest
runner and the mocha-based vscode-test integration runner (.vscode-test.mjs
globs src/shared/**/*.test.js). vitest's jest-compat matcher toMatchObject
does not exist in the mocha runtime, so the test passed under vitest but
threw "toMatchObject is not a function" in the integration suite, failing
the nightly publish. Assert the two provider fields with toBe, which works
under both runners.
2026-06-23 17:05:53 +09:00
Saoud Rizwan d0b84bbfa3 test(vscode): warm catalog import to fix flaky 5s timeout (#11711)
The first test in catalog.test.ts paid the cost of dynamically importing
./catalog (which pulls in @cline/core, @cline/llms and @cline/shared)
inside its own 5s test timeout, intermittently failing CI/nightly runs.
Warm the import once in beforeAll so the cost falls outside any per-test
clock.
2026-06-23 17:05:53 +09:00
Saoud Rizwan e2bb10c1f2 feat(vscode): expose additional SDK providers (#11703)
* feat(vscode): expose additional SDK providers

* fix(vscode): reserve skipped provider enum slots

* fix(vscode): keep Z.AI Coding Plan provider-specific
2026-06-23 17:05:53 +09:00
Saoud Rizwan 1bc1e832a4 fix(sdk): route LiteLLM model fetches through SDK (#11705)
* fix(vscode): improve LiteLLM model fetch errors

* fix(vscode): align LiteLLM fetch return contract

* fix(sdk): improve LiteLLM private model fetch

* chore(vscode): drop duplicate LiteLLM fetch changes

* fix(vscode): route LiteLLM refresh through SDK
2026-06-23 17:05:52 +09:00
Saoud Rizwan 7802e29629 fix(vscode): honor OpenAI-compatible model settings (#11710)
* fix(vscode): honor OpenAI-compatible model settings

* fix(vscode): simplify OpenAI-compatible model bridge

* fix(vscode): respect OpenAI-compatible image support
2026-06-23 17:05:52 +09:00
Max c3f6d4c413 fix(vscode): use Codex OAuth credentials (#11691) 2026-06-23 17:05:52 +09:00
BarreiroT 1b1cfd9457 Fix tests 2026-06-23 17:05:52 +09:00
BarreiroT 025ead194b fix tests 2026-06-23 17:05:52 +09:00
Tomás Barreiro 902a0f0cb7 Fix ClinePass auth (#11680)
* Return local providers with ClinePass in the new extension

* Fix import

* Fix ClinePass auth
2026-06-23 17:05:52 +09:00
Tomás Barreiro 7503be4ec9 Return local providers with ClinePass in the new extension (#11678)
* Return local providers with ClinePass in the new extension

* Fix import
2026-06-23 17:05:52 +09:00
Max Paulus 🥪 907ce31cbf fix broken CI tests 2026-06-23 17:05:52 +09:00
Max Paulus 🥪 62c7bb36b5 fix local build not picking up .env file 2026-06-23 17:05:52 +09:00
Max Paulus 🥪 350b6e8dbf update vscode ignore
vsix bundling failed because of some unignored files
2026-06-23 17:05:52 +09:00
Max Paulus 🥪 7d40772d20 fix broken integ tests 2026-06-23 17:05:51 +09:00
Max Paulus 🥪 336d3104d2 remove storage tests from vitest
- these run under node resolution so they can see "bun:test" imports.
- these tests will get run by scripts/run-bun-unit-tests.ts instead
2026-06-23 17:05:51 +09:00
Cline Agent dd0c3b7e39 fix: repair SDK ClinePass webview rebase
Restore the webview feature-flag hook needed by the ClinePass onboarding/settings UI, but implement it against the existing posthog singleton instead of posthog-js/react so tests do not pull in a second React copy.

Make ClinePass settings follow the SDK provider-catalog pattern: render the Cline account card, resolve models with useProviderModels("cline-pass"), and persist selections with useProviderConfig/useProviderModelSelection for providerId="cline-pass". Remove the stale origin/main props that tried to drive the SDK-era ClineModelPicker, which is intentionally Cline-provider specific.

ClinePass remains hidden by the ext-cline-pass flag in settings/onboarding, and its model info hides token usage costs because billing is subscription-based.
2026-06-23 17:05:51 +09:00
Cline Agent 5300e9506b fix: post-rebase ClinePass plumbing for SDK migration
Resolve type-check and test breakages from rebasing the ClinePass
feature (origin/main) onto the SDK migration branch:

- provider-keys: re-add cline-pass to ProviderKeyMap and
  NON_SDK_PROVIDER_DEFAULTS (removed by the 'remove unused code'
  commit which predated ClinePass), so getProviderModelIdKey and
  getProviderDefaultModelId handle the cline-pass provider.
- provider-id: register 'cline-pass' in KNOWN_API_PROVIDERS so the
  Record<ApiProvider, true> constraint is satisfied.
- refreshClineRecommendedModels: add optional 'clinePass' field to
  ClineRecommendedModelsData so the RPC handler can map it into the
  proto response without a type error.
- refreshClineRecommendedModelsRpc: guard models.clinePass with ?? []
  for the same reason.
- handleClinePassProviderSelection: pass undefined (not null) to
  accountService.switchAccount to match the SDK signature.
- provider-keys.test: remove a duplicate closing brace left by the
  conflict resolution.
- Biome formatting (asNeeded semicolons) applied by check-types.
2026-06-23 17:05:46 +09:00
Dominic Cooney f105507c87 fix(vscode): reliable MCP OAuth on the SDK extension (ENG-2108, CLINE-2304) (#11529)
* fix(vscode): store MCP OAuth in shared settings file like the CLI (ENG-2108)

VSCode stored MCP OAuth tokens in a single mcpOAuthSecrets secrets blob
keyed by sha256(name:url), while the CLI/SDK store per-server oauth state in
cline_mcp_settings.json. The two never interoperated (CLI auth was invisible to
VSCode), and VSCode's read-whole-blob/write-whole-blob through StateManager's
non-refreshing cache meant concurrent windows clobbered each other's tokens.

- Store MCP OAuth state in the shared settings file in @cline/core's format.
- Reads are fresh from disk; writes are scoped read-modify-write of one
  server's oauth key via updateMcpServerOAuthState (now atomic temp+rename).
- Replace the vscode:// callback flow with HTTP-based token collection via
  authorizeMcpServerOAuth (same local loopback flow the CLI uses).
- Reconnect an unauthenticated server when its tokens appear (e.g. CLI auth).
- One-time migration of legacy mcpOAuthSecrets tokens into the shared file.
- Remove McpOAuthRedirectResolver, mcpOAuthFlow, completeOAuth, and the
  mcp-auth URI callback route.

* feat(vscode): add --instances/--random-port to MCP OAuth test server

Lets you start several independent test servers, each on its own OS-assigned
random port, so you can add multiple streamableHttp MCP servers to Cline at
once and exercise concurrent OAuth flows. baseUrl now reflects the actually
bound port so discovery metadata and redirect URIs stay correct under random
ports.

* fix(vscode): stop MCP OAuth handshake writes from livelocking the settings watcher (ENG-2108)

Now that codeVerifier/clientInformation live in the shared settings file, the
MCP SDK's per-connect-attempt saveCodeVerifier() writes were tripping the
settings watcher, which re-entered updateServerConnections -> connectToServer
-> another write, looping forever. It was especially bad with two+
unauthenticated servers, where each server's verifier churn re-triggered the
other (visible as a flickering, ever-changing codeVerifier nonce).

The watcher now compares a connection-relevant fingerprint (full per-server
config minus the oauth block, plus a boolean for whether an access token
exists) and skips writes that only churn OAuth-handshake fields. A token
appearing/disappearing still changes the fingerprint, so CLI/other-window
authorization continues to trigger a reconnect via serverGainedOAuthTokens.

* feat(vscode): print paste-ready MCP settings fragment from OAuth test server

On startup the test server now emits an mcpServers JSON fragment (nested
transport shape, matching cline_mcp_settings.json) alongside the banner, so you
can paste it straight into the settings file instead of hand-writing it. With
--instances the entries get distinct names (oauth-test-1, ...), each carrying
its actual bound port.

* fix(vscode): atomic MCP settings writes + fingerprint gate; drop timer guards (CLINE-2097)

Deleting one MCP server could empty the whole list. Root cause: settings
writes were non-atomic (fs.writeFile), so chokidar (and any other process)
could read a transient empty/torn file mid-write and reconcile to zero servers.
The previous fix only masked this with a per-process isUpdatingClineSettings
boolean cleared on a 300ms timer — it did nothing for the CLI or other windows
and was racy.

Replace both timer guards (isUpdatingClineSettings, isUpdatingFromRemoteConfig)
with two deterministic, process-agnostic mechanisms:

- writeSettingsFile(): atomic temp-file + rename for every settings write, so
  any reader always sees a complete file. Holds for any number of concurrent
  writers (CLI, multiple windows, SDK OAuth handshake).
- content fingerprint: the watcher reconciles only when the connection-relevant
  view changed. writeSettingsFile pre-seeds the fingerprint so our own write is
  a no-op, while a genuine change from any other process is still processed.
  Because reconcile is idempotent and reads are never torn, a missed
  suppression is at worst a redundant reconnect, never data loss.

All RPC writers (toggle disabled, autoApprove x2, timeout, add, delete) and the
remote-config sync now go through writeSettingsFile. Removes all setTimeout(.,
300) flag juggling.

* feat(vscode): add a non-guessable 'frozzle' tool to the MCP OAuth test server

The MCP OAuth test server now serves tools/list + tools/call exposing a
'frozzle' tool whose output cannot be derived without calling it (reverse the
string and swap each letter's case, wrapped in guillemets). This gives an eval
a reliable end-to-end signal that the OAuth-authenticated MCP round-trip really
happened: a correct 'frozzle <text>' answer can't be hallucinated. The
transform is easy to verify at a glance and invertible. Adds frozzle.test.ts.

* fix(sdk): drop lingering OAuth callback sockets on close so deny->approve re-auth works (ENG-2108)

The local OAuth callback server's close() called Server.close(), which only
stops accepting new connections and lets existing keep-alive sockets linger.
The browser / global-fetch connection pool keeps such a socket to the fixed
callback port (1456) alive. So after the user denied an MCP OAuth request and
retried, the retry's approve callback could be delivered over the pooled socket
to the FIRST (already-settled) server. That server's settle() was a no-op, so
waitForCallback() never resolved, finishAuth()/token exchange never ran, and no
token was saved — the server stayed unauthenticated (the deny->approve repro).

Call server.closeAllConnections() in close() so no pooled socket outlives the
server. Adds a regression test driving a keep-alive agent across close().

* fix(vscode): actually reconnect MCP server when toggled back on (ENG-2108)

toggleServerDisabledRPC only flipped the in-memory disabled flag and set status
to 'connecting', but never rebuilt the connection. A disabled server's
connection has no live transport/client, so re-enabling left it stuck on the
yellow 'connecting' indicator forever and never re-advertised its tools to the
agent.

Tear down and rebuild the connection through deleteConnection + connectToServer
(which opens a real transport when enabled, or a disconnected stub when
disabled), then notifyWebviewOfServerChanges so the SDK session's tool list is
refreshed. OAuth state is preserved (deleteConnection doesn't clear it). Adds
McpHub.toggleServerDisabledRPC.test.ts.

* fix(vscode): reload MCP tools silently without chat spam (ENG-2108)

Restarting the SDK session to pick up MCP tool changes appended visible chat
messages ('MCP tools changed - reloading...' and 'MCP tools reloaded
successfully...') plus a completion_result banner. Toggling several servers
piled up many of these. Tool reloading should be transparent.

Emit only the session status transitions (running -> idle) via
emitSessionEvents([], ...) instead of appendAndEmit, so no chat messages or
completion banner are shown. Genuine reload failures still surface an error
message. Updates sdk-mcp-coordinator.test.ts accordingly.

* docs(mcp): clean up comments to describe current behavior

Revise comments across the MCP OAuth and settings code to document the code as
it stands, dropping references to prior implementations, task IDs, and
before/after narration. Also reflow the auth-server regression test to the
repository's formatter. No behavior change.

* fix(vscode): atomic fallback write in remote MCP sync; document sync OAuth I/O

Make the no-McpHub branch of syncRemoteMcpServersToSettings write via an
atomic temp-file + rename so a concurrent reader never observes a torn or
empty settings file, matching every other settings write.

Document why the OAuth state read-modify-write in McpOAuthManager is
synchronous: it serializes this process's shared-file updates without a
Promise queue, which we prefer over async I/O for reliability of the
cross-process settings file.

* fix(mcp): serialize settings read-modify-writes

* docs(vscode): clarify MCP settings create race

* fix(vscode): create MCP settings atomically

* fix(cli): keep clearing missing MCP OAuth state a no-op

* fix(vscode): avoid yielding while holding MCP settings lock (#11596)

* fix(mcp): async lock acquisition for VSCode MCP settings/OAuth writes

Add updateMcpSettingsFile/updateMcpServerOAuthStateAsync to @cline/core that
yield the event loop while acquiring the cross-process settings lock instead of
blocking it with Atomics.wait. The critical section stays synchronous and the
mutator stays pure, so the lock is never held across an await and serialization
is preserved without an in-process queue.

Route the VSCode extension host's OAuth state writes (McpOAuthManager) through
the async variant so a connection-time OAuth callback can no longer freeze the
extension host event loop or deadlock against an in-flight updateMcpSettingsFile
whose lock-releasing continuation needs the loop.

Unify the sync and async acquisition paths on a shared reentrancy guard
(activeLocks) so a nested settings update on the same file fails fast instead of
self-deadlocking.

Tests: contended async serialization asserting zero Atomics.wait calls, async
stale-lock reclaim, reentrancy fail-fast, and uncontended run+release.

* fix(mcp): bootstrap missing settings file inside the lock; tidy docs

Creating the MCP settings file now happens in one place: the locked
read-modify-write helpers. A missing file reads as an empty settings object, so
the first write to a fresh path (e.g. a fresh-install `cline mcp add`) creates
it inside the lock instead of throwing ENOENT. The SDK (updateMcpSettingsFile /
updateMcpSettingsFileSync) and the VSCode lock helper share this contract, so
callers no longer need to pre-create the file. Add regression tests for the
SDK, the CLI wizard addServer(), and the VSCode helper on a missing path.

Also flag the synchronous SDK entry points (updateMcpSettingsFileSync,
updateMcpServerOAuthState) as preferring their async siblings, with a TODO to
delete them once all callers migrate, and tighten the lock-helper doc comments
to describe current behavior.

* fix(vscode): finish npm->bun migration in dev tooling, tasks, and docs

The npm->bun migration (#11632) updated package scripts, .vscodeignore and .vscode-test.mjs but left a trail of npm/npx/node invocations in editor configs, dev scripts, and docs. Following the breadcrumbs from 'npm run protos':

- .vscode/launch.json: standalone-core debug uses 'bun <file>.ts' (was npx tsx); Open Storybook uses 'bun run' (was npm run).
- .vscode/tasks.json: all task commands use 'bun run' (was npm run).
- scripts/run-extension-host.sh and .claude/hooks/claude-code-for-web-setup.sh: 'bun run' (was npm run).
- debug-harness/server.ts: shebang 'bun'; build steps use 'bun run protos', 'bun esbuild.mjs', 'bunx vite build' (were npm/node/npx).
- dev script shebangs (test-hostbridge-server, test-standalone-core-api-server, testing-platform-orchestrator, interactive-playwright): '#!/usr/bin/env bun' (was npx tsx).
- WebviewProvider HMR hint, e2e README, copilot-instructions, PR template, mcp-oauth-test-server docs, generate-state-proto message, tsconfig.test comment, state-keys test comment: bun.

Left untouched (correct per .clinerules/bun-and-node): Node-runtime invocations (node build.mjs), prebuild-install --target=<node>, vsce, 'npm install -g cline' (user CLI install), and App.stories.tsx mock chat fixtures.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-06-23 17:05:46 +09:00
Dominic Cooney ac353e9725 Migrate apps/vscode from npm/node to bun (#11632)
* chore(vscode): migrate package management & build from npm/node to bun

Fold apps/vscode (+ webview-ui, testing-platform) into the root bun
workspace so the extension consumes the local @cline/* SDK packages via
workspace symlinks instead of pinned published versions, eliminating the
SDK vendoring cycle. Node remains the runtime (extension host, standalone
cline-core, esbuild platform:node, prebuild-install ABI target).

- root: drop "!apps/vscode", add nested members, relocate overrides to
  root, add trustedDependencies [better-sqlite3, grpc-tools]
- apps/vscode: @cline/* -> workspace:*, scripts -> bun/bunx,
  npm-run-all -> bun --parallel, drop cross-env; keep esbuild + vite;
  declare previously-hoisted phantom deps (nice-grpc-common, playwright)
- package-standalone.mjs: npm install -> bun install (isolated dist dir)
- CI: setup-bun + single root bun install --frozen-lockfile, build:sdk
  before extension build, better-sqlite3 binary + zero-test guards;
  publish workflows intentionally keep setup-node for vsce/ovsx
- docs/comments: curated pass (keep-list vs rewrite-list), add
  apps/vscode/docs/bun-migration-notes.md guard doc
- delete npm lockfiles (root bun.lock authoritative)

Deferred to follow-up PRs: test-runner migration to bun test (Phase 4)
and devDep cleanup (Phase 6).

* test(vscode): add bun test foundation for the vitest-native unit suites

Phase 4a of the test-runner migration. Adds a bun test runner that
reaches full parity (582 pass / 0 fail / 50 files) with the existing
vitest SDK-adapter + model-catalog suite, without touching the
@vscode/test-cli integration tests or the webview vitest suite.

- bunfig.toml: [test] preload
- src/test/bun-test-preload.ts: mock.module() shadows `vscode` and
  `@cline/core` with their unit-test stubs (bun's onResolve plugin hook
  does not intercept host/symlinked specifiers); seeds real @cline/core
  export names as undefined to satisfy bun's strict ESM named-import
  linking; full vitest->bun:test shim (vi.fn/mocked/spyOn, describe/it/
  expect/before*/after*)
- scripts/run-bun-tests.ts: mirrors vitest.config.ts include[] exactly and
  runs with --parallel for per-file mock isolation (bun test's single-process
  default lets mock.module clobber across files)
- test:bun script

* test(vscode): migrate node-side unit suite from mocha to bun test

Phase 4b of the test-runner migration. The standalone mocha unit runner
(.mocharc spec: __tests__/* + test/services/**) was already broken under
bun (mocha was a phantom dependency — only @types/mocha/ts-node were
declared, npm hoisted mocha transitively). Migrate it to `bun test`.

- codemod 77 files: import { ... } from "mocha" -> "bun:test", renaming
  before->beforeAll / after->afterAll at imports and call-sites; chai,
  should and sinon kept as libraries (they work under bun test)
- convert sinon.stub() on ESM namespace exports to mock.module()/spyOn
  (bun loads real ESM: "ES Modules cannot be stubbed")
- scripts/run-bun-unit-tests.ts: runs the .mocharc spec set with one
  isolated `bun test` process per file (Bun.spawn + concurrency pool),
  restoring vitest-forks module-registry isolation (bun's single-process
  default lets mock.module leak across files)
- scripts/codemod-mocha-{to-bun,this}.ts: one-shot migration tooling
- test:unit now runs the bun unit runner; CI calls bun + a non-zero
  pass-count guard instead of `bunx nyc ... mocha`
- tsconfig: add root node_modules/@types to typeRoots so `bun:test`
  types resolve under tsc; cast loose os.userInfo mocks in shell.test

Result: unit suite 58 files / 880 pass / 0 fail; vitest set still
582/0. @vscode/test-cli integration tests and webview vitest unchanged.

* chore(vscode): remove dead mocha-runner deps and artifacts

Phase 6 cleanup after the bun test migration. The standalone mocha unit
runner is gone (replaced by scripts/run-bun-unit-tests.ts), so its
config and now-unused devDependencies are removed.

- remove dead files: .mocharc.json, tsconfig.unit-test.json,
  src/test/requires.ts, .nycrc.unit.json
- remove unused devDeps: @types/mocha, @types/proxyquire, ts-node,
  tsconfig-paths, cross-env, npm-run-all, nyc, proxyquire, husky
  (root owns the husky hook; chai/should/sinon stay — used as libs)
- install:all -> single root `bun install` (workspace covers webview-ui)
- drop .mocharc.json / .nycrc*.json from CI paths-filters and
  .vscodeignore; add bunfig.toml to the filters

Verified: check-types clean, unit 880/0, vitest 582/0.

* fix(vscode): import bun:test globals in tests that relied on ambient @types/mocha

CI Quality Checks (clean `bun install` without @types/mocha) surfaced
TS2582/TS2304 "Cannot find name 'describe'/'it'/'beforeEach'" in test
files that used the global mocha/jest test functions without importing
them. The Phase 4b codemod only rewrote files that imported from
"mocha"; these used ambient globals, so they were missed (and passed
locally because a stale @types/mocha lingered in node_modules).

Add explicit `bun:test` imports (before->beforeAll, after->afterAll in
TelemetryService.test.ts). chai/sinon stay as libraries.

Verified against a clean tree (no @types/mocha): check-types 0 errors,
unit suite 58 files / 880 pass / 0 fail.

* style(vscode): biome-format migrated test files + codemod scripts

The mocha->bun:test codemod and manual import edits left formatting that
didn't match biome (the CI `format` check, which validates files changed
since main, flagged them). Also narrow setup.ts's bun:test import to the
actually-used beforeEach/afterEach (describe/it only appear in a JSDoc
example), fixing a noUnusedImports lint error.

ci:check-all (check-types + lint + format) now passes locally.

* fix(webview-ui): declare phantom deps + pin React 18 types under bun workspace

Folding webview-ui into the bun workspace changed its install topology
from an isolated npm flat tree to the shared hoisted store, surfacing
two classes of pre-existing latent issues that npm hoisting had masked:

1. Phantom dependencies: src imports `marked`, `unist`, `unist-util-visit`
   and `@heroui/theme` directly but never declared them. Declared them
   (marked ^15, unist-util-visit ^5, @types/unist ^3, @heroui/theme 2.4.26).
2. React types: @testing-library/react's optional peer pulls @types/react@19
   into a resolvable location; tsc mixed it with the toolkit's React 18
   types (React 19 dropped Component.refs), breaking 452 JSX usages. Pin
   react/react-dom type resolution to webview-ui's React 18 copy via
   tsconfig paths.

build:webview (tsc -b && vite build) and ci:check-all now pass.

* fix(vscode): restore @types/mocha for integration build + add bun:test types

The @vscode/test-cli integration runner still uses mocha, and
tsconfig.test.json compiles all src/**/*.test.ts (including bun-migrated
files) to out/. So:
- restore @types/mocha (integration compile needs the mocha ambient types)
- add `bun` to tsconfig.test.json types + root @types to both tsconfig
  typeRoots so `bun:test` resolves under tsc for the migrated tests

* fix(vscode): declare glob — phantom dep used by package-standalone.mjs

scripts/package-standalone.mjs imports `glob` but it was never declared
(resolved transitively under npm's flat hoist). Under the bun workspace
store it's unresolvable, failing postcompile-standalone with
ERR_MODULE_NOT_FOUND. Declare glob ^11 (modern named-export API).

compile-standalone now produces dist-standalone/standalone.zip.

* fix(ci): strip ANSI before vitest zero-test guard grep

The vitest summary line colorizes the count ("Tests  <ansi>582 passed"),
so the count isn't adjacent to the "Tests" label in raw bytes and the
guard regex failed even though 582 tests passed. Strip ANSI escapes
before matching.

* fix(vscode): declare minimist — phantom dep in testing-platform-orchestrator

scripts/testing-platform-orchestrator.ts imports `minimist` (undeclared,
resolved transitively under npm hoist). Declare it so the testing-platform
integration job runs under the bun workspace store.

* fix(vscode): restore tsconfig-paths for integration runner; tp-orchestrator uses bun

Phase 6 over-removed tsconfig-paths: test-setup.js (loaded by the
@vscode/test-cli mocha integration runner) requires it to resolve @/
aliases in the compiled out/ tree — the extension host test runner failed
with "Cannot find module 'tsconfig-paths'". Restore it. Also switch the
testing-platform spawn from `npx ts-node index.ts` to `bun index.ts`
(bun runs TS natively; avoids the removed ts-node).

* fix(vscode): route tests by bun:test import marker; integration runner stays mocha

The mocha->bun codemod swept up tests that the Node-based @vscode/test-cli
integration runner compiles/runs, which cannot load the `bun:test` builtin
(and some need the real VSCode host). Establish a single source of truth:
a *.test.ts is bun-runner-owned IFF it imports "bun:test".

- run-bun-unit-tests.ts: discover files by the bun:test import marker
  (not fixed globs), so every migrated file runs under bun.
- build-tests.js: generate a tsconfig that excludes all bun:test files
  from the integration compile (json5-parsed), so out/ never contains
  bun:test; gitignore the generated config.
- .vscode-test.mjs: exclude the bun unit dirs from the runner globs.
- revert host-dependent tests (hostbridge/*, extension, terminal,
  FileContextTracker host bits) and 3 files with sinon-on-ESM/behavioral
  issues (ClineIgnoreController, mentions, TelemetryService) back to
  mocha; they run on @vscode/test-cli as before.

Verified: check-types 0 errors; compile-tests 0 bun:test in out/;
bun unit 65 files/962 pass/0 fail; vitest 582/0.

* fix(vscode): declare mocha — phantom dep for @vscode/test-cli integration runner

The @vscode/test-cli extension host loads `mocha` at runtime to run the
integration suite, but only @types/mocha was declared (npm hoisted the
mocha package transitively; bun's store does not expose it). The host
failed with "Cannot find module 'mocha'". Declare mocha ^11.7.4 (matches
@vscode/test-cli's own range).

* fix(vscode): robust Windows protoc-gen-ts_proto plugin resolution under bun

build-proto.mjs hardcoded node_modules/.bin/protoc-gen-ts_proto.cmd for
Windows, but bun's workspace store places/extensions the bin shim
differently (hoist + .cmd/.bunx), so Windows protos failed with
"protoc-gen-ts_proto: The system cannot find the file specified". Probe
the local + root .bin with known shim extensions instead. Also update
the testing-platform usage string (ts-node -> bun).

* fix(vscode): generate node .cmd wrapper for ts-proto plugin on Windows

The previous probe found bun's `.bunx` shim, but protoc cannot exec it
("%1 is not a valid Win32 application"). Instead, on Windows generate a
small .cmd wrapper that runs the resolved protoc-gen-ts_proto JS via
`node`, which protoc can execute regardless of package manager. POSIX
path (direct JS bin) is unchanged.

* fix(vscode): package VSIX with --no-dependencies (bundled) to stop monorepo traversal

Under the bun workspace, @cline/* are workspace:* symlinks pointing to
../../../../sdk/packages/*. vsce, walking the dependency tree, followed
them out of apps/vscode and packaged the whole monorepo (../, ~84MB incl.
root node_modules and .env), which crashed vsce's secret scanner and
failed all e2e jobs.

The extension is fully esbuild-bundled into dist/extension.js, so vsce
should not walk node_modules at all. Add --no-dependencies to every
vsce/ovsx package/publish path (e2e build, marketplace, nightly), and
tighten .vscodeignore to drop nested node_modules and dev-only inputs
(scripts, proto, testing-platform, bunfig, esbuild.mjs, etc.).

Result: VSIX is 39 files / ~7 MB and the secret scan passes.

* docs(vscode): tighten bun/node comments and consolidate into a clinerule

- add .clinerules/bun-and-node.md (eternal-now: bun=tooling, node=runtime,
  keep-list, and the bun:test-vs-mocha test routing rule); remove the
  apps/vscode/docs/bun-migration-notes.md migration doc and point
  .clinerules/general.md at the rule (single-line bullet matching the file).
- fix the hotfix-release note: there is no infra step that regenerates the
  lockfile; a CHANGELOG+version bump leaves bun.lock consistent (workspace
  versions aren't pinned) and publish runs --frozen-lockfile.
- reframe runner/preload comments to describe the code as-is (drop
  "migrated off mocha"/codemod history); add a TODO on the bun-test preload
  to migrate suites off the vitest `vi` shim to native bun:test and delete it.
- remove the one-shot mocha->bun codemod scripts.

* fix(debug-harness): pin debugee VSCode version so bundled Playwright can drive it

The harness downloaded "stable" VSCode (currently 1.125 / Electron 42),
which the bundled Playwright cannot drive — `_electron.launch()` hangs
until its 60s timeout (Electron started and a window appeared, but the
launch handshake never completed). Default to a known-good version
(1.103.0, matching the e2e CI matrix) and allow override via
VSCODE_TEST_VERSION.

* fix(webview): render under bun workspace — dedupe React, drop stale codicons link

The webview mounted but crashed before rendering (blank sidebar; e2e
"Login to Cline" never visible) with "Cannot read properties of null
(reading 'useRef')" — the classic two-React-copies / null hook dispatcher.
Under the bun workspace, sibling packages pull react@19 into the shared
store and a transitive webview dep resolved a second React instance into
the vite bundle. Add resolve.dedupe + pin react/react-dom to webview-ui's
own React 18 copy.

Also drop the separate `<link>` to node_modules/@vscode/codicons in the
webview HTML: the webview's index.css already @imports codicons, so the
font is bundled into the build assets. Under bun that node_modules path
is a symlink to the root store (outside the webview localResourceRoots)
and isn't packaged with --no-dependencies, so the link 404'd; the bundle
covers it. Re-scope the .vscodeignore nested-node_modules exclude so it
no longer shadows the codicons re-include.

* fix(debug-harness): disable GPU so the debugee renders in headless/VM envs

On headless/VM GPU stacks the debugee Electron's GPU process crash-loops
("Exiting GPU process during initialization" / CreateCommandBuffer
kTransientFailure), killing the window before Playwright finishes
attaching and tripping the 60s launch timeout. Force software rendering
(--disable-gpu and friends) for a stable harness launch.

* fix(debug-harness): survive launch failures; configurable, longer launch timeout

The harness crashed (whole bun process exited) whenever VSCode launch
failed/timed out: Playwright emits a late unhandled rejection on the dead
CDP transport after we've already handled the launch error, and the
default behavior takes the HTTP server down with it — forcing a full
restart just to retry.

- Add process-level unhandledRejection/uncaughtException guards so stray
  async errors are logged and the server keeps serving (retry via `launch`).
- On launch failure, close the orphaned Electron so a retry isn't blocked.
- Make the _electron.launch timeout configurable (--launch-timeout) and
  raise the default to 120s for cold launches; document VSCODE_TEST_VERSION.

* fix(ci): address review feedback — vsix --no-dependencies, drop stale coverage path, Windows shell

- ext-vscode-publish-stable.yml: add --no-dependencies to the release-artifact
  `vsce package` (Max's catch). Without it, vsce follows the @cline/* workspace
  symlinks out of the package and bloats the .vsix with the whole monorepo.
- ext-vscode-test.yml: drop the stale apps/vscode/coverage-unit/lcov.info upload
  path (Max's catch). That file was produced by the removed nyc unit-coverage
  step (.nycrc.unit.json); nothing generates it now.
- ext-vscode-test-e2e.yml: the better-sqlite3 assert step ran under the Windows
  runner's default pwsh and failed to parse the POSIX test. Pin it to `shell: bash`
  (Git Bash ships on windows-latest); the non-e2e job already defaults to bash.

---------

Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-06-23 17:05:46 +09:00
Max 9c8d2ffa62 Improve onboarding funnel metrics (#11650)
* improve onboarding metrics

* fix onboarding page view dedupe

---------

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-06-23 17:05:34 +09:00
Max Paulus 🥪 db250c5f9f fix package lock issues post rebase 2026-06-23 17:05:33 +09:00
Max 6a1f64bc13 fix(vscode): persist Vertex provider settings (#11565)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-06-23 17:05:33 +09:00
Max 7f9c6f7e84 fix(vscode): preserve legacy task metadata on resume (#11570)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-06-23 17:05:33 +09:00
Dominic Cooney ef51b8bbbd chore(vscode): remove stale HuggingFace provider test 2026-06-23 17:05:33 +09:00
Max Paulus 🥪 ad83b2cdf5 fix standalone e2e test 2026-06-23 17:05:33 +09:00
Max Paulus 🥪 1d4ec2ac65 bump sdk version 2026-06-23 17:05:33 +09:00
Dominic Cooney 2ee41b70c2 fix(vscode): simpler login UX (ENG-2174) — remove inline provider gate, restore debug harness (#11534)
* remove inline no-usable-provider sign-in banner; rely on inference-time errors

The "Sign in to Cline or set up a provider" banner gated chat input on a
parallel provider-usability heuristic that mis-detected BYOK setups
(Bedrock profile/IAM, Vertex ADC) and its sign-in button discarded the
device code. Remove the component and the hasUsableProvider plumbing.

Auth/config problems now surface at inference time, where handling
already exists:
- cline provider without a token -> emitClineAuthError -> ErrorRow
  renders the Sign in button with the device-code display
- any other misconfigured provider -> say:"error" row

Also deletes the now-dead sdk/provider-usability module and adds a test
that failed session start emits a plain chat error.

* restore debug-harness server deleted in 0bfbfb944

Commit 0bfbfb944 ("delete unused files") removed src/dev/debug-harness/server.ts
as dead code, but it is a dev tool launched directly via
`npx tsx src/dev/debug-harness/server.ts` (see its README and
.clinerules/debug-harness.md) — no static import graph reaches it, which
is why the unused-file analysis flagged it. The README, the .clinerules
docs, and the CLINE_CAPTURE_BROWSER / __clineHandleUri hooks in
extension.ts and utils/env.ts that exist solely for this harness all
survived the deletion, leaving them dangling.

Restored verbatim from 0bfbfb944~1; verified it boots and listens on
:19229.
2026-06-23 17:05:33 +09:00
Saoud Rizwan ff2789a803 fix(vscode): restart session when user switches provider (#11507)
* fix: format Cline OAuth tokens in provider config

* fix(vscode): restart SDK session on provider switch

* fix(vscode): serialize SDK provider restarts

* chore(vscode): keep provider switch PR scoped

* fix(vscode): simplify deferred provider restarts
2026-06-23 17:05:33 +09:00
Mikołaj Kondratek 9b22057939 fix: thread proxy/CA-aware fetch into the SDK inference path (#11462)
* fix: thread proxy/CA-aware fetch into the SDK inference path

The main agent loop did not receive the host's proxy/CA-aware fetch, so
on JetBrains and the CLI inference over a corporate proxy or to a
self-signed/private-CA endpoint failed with "unable to get local issuer
certificate". This regressed at the SDK cutover: the pre-SDK CLI
(2.18.0) constructed provider clients with a proxy-aware fetch directly,
while the SDK agent loop fell back to bare global fetch (CLINE-2353).

Two layers:
- App (cline-session-factory.ts): always build CoreSessionConfig.
  providerConfig and carry the proxy-aware fetch from @/shared/net, not
  just for Bedrock. In VSCode this fetch is global fetch, so behavior is
  unchanged there; in the standalone (JetBrains) build it is undici with
  EnvHttpProxyAgent.
- SDK (handler-factory.ts): forward providerConfig.fetch into
  createGateway both as the top-level fallback fetch and per provider, so
  the gateway's provider clients use it. Passing undefined is a no-op
  (registry resolves config?.fetch ?? defaults?.fetch ?? fallbackFetch),
  so other SDK consumers are unaffected.

The SDK change covers every host that supplies a fetch; the app change
covers VSCode and JetBrains. The CLI builds its session config through a
separate path (apps/cli) that does not yet wire a proxy-aware fetch, so
CLINE-2353 on the CLI surface is addressed in a follow-up.

Adds a handler-factory unit test asserting the host fetch is forwarded
to createGateway at both the top level and per provider.

* fix: deterministically install proxy dispatcher in standalone core

The proxy/CA-aware undici dispatcher is installed as a side effect of
loading @/shared/net (it calls setGlobalDispatcher with EnvHttpProxyAgent
in the standalone build). The standalone entry cline-core.ts did not
import that module, so the dispatcher was only installed incidentally
when some other transitively-imported module happened to pull it in. A
future change to the import graph could silently drop proxy/CA support on
JetBrains.

Import @/shared/net for its side effect, first, so the install is
deterministic and runs before any network use (CLINE-2353).

Standalone-only hardening; VSCode uses global fetch and is unaffected.
2026-06-23 17:05:33 +09:00
Saoud Rizwan 41e5e6236c fix(vscode): fix duplicate tool row when changing plan/act mode during pending tool approval (#11437)
* fix(vscode): suppress duplicate tool row when a mode change clears a pending approval

Switching plan/act while a tool approval was pending duplicated the
approval row in chat. clearPending resolved the pending approval as
denied, which unblocks the core; the core then emits the denied tool
call's content_start/content_end events before the mode coordinator's
abort lands. The interactive deny paths record the denial in the
message translator state so those events are suppressed, but
clearPending skipped that step, so the translator rendered the events
as a fresh say:tool row next to the still-visible approval ask.

clearPending now records the denial through recordDeniedToolApproval
before resolving, mirroring resolvePendingToolApproval. This covers all
clearPending callers: mode changes, task cancel, and task clear.

* refactor(vscode): trim the clearPending denial fix to its minimal shape

Keep clearPending's original structure, only inserting the denial
recording before the resolve. Drop the end-to-end suppression test:
translator suppression for recorded denials is already covered by
message-translator-approval-denial.test.ts, and the clearPending
recording is covered by the extended unit assertion.
2026-06-23 17:05:32 +09:00
Saoud Rizwan 7b01a6d595 fix(vscode): restore aggressive pin-to-bottom auto scroll in chat view (#11436)
* fix(webview): restore aggressive pin-to-bottom auto scroll in chat view

The auto-scroll effect only fired on groupedMessages.length changes, but in
the SDK-migrated extension new content can appear in the chat without the
message list length changing:

- The Thinking placeholder row is driven by turnState alone (e.g. the plan
  to act switch auto-continues the task with no new message), and it was
  appended to the rendered list inside MessagesArea where the scroll hook
  never saw it.
- New tool messages merge into the trailing tool group, and the thinking
  placeholder gets swapped for a real reasoning row at constant length.

Fixes:
- Lift the thinking placeholder computation out of MessagesArea into a new
  useDisplayedGroupedMessages hook so ChatView feeds the same list to both
  Virtuoso and useScrollBehavior; the placeholder appearing now pins to
  bottom like a real message.
- Key the pin effect on the tail message ts (skipping the placeholder) in
  addition to list length, covering in-place tail changes.
- Re-engage auto scroll when turnState.phase transitions into streaming. In
  the old extension every turn start was accompanied by a user send/button
  click that reset disableAutoScrollRef; turnState-driven turn starts like
  plan to act auto-continue have no webview-side action, so handle it in
  the scroll hook.

* refactor(webview): replace scroll fix with minimal single-file version

Same three behaviors as the previous commit (pin when the thinking
placeholder appears, pin on in-place tail changes, re-engage auto scroll
when a turn starts streaming) but implemented as two small effects in
MessagesArea, which already has both the rendered list and scrollBehavior
in scope. Reverts the useDisplayedGroupedMessages hook extraction and the
ChatView/useScrollBehavior changes; net diff vs the base branch is now
one file.
2026-06-23 17:05:32 +09:00
Dominic Cooney 8db8fb98a1 test(vscode): exercise full SDK structured edit flow in file-edit e2e (#11442)
* test(vscode): exercise full SDK structured edit flow in diff.test.ts e2e (ENG-2042)

The SDK runtime executes structured (OpenAI-format) tool calls instead of parsing XML-style tool syntax out of assistant text. Teach the e2e mock server to stream an editor tool call for edit_request (arguments split across deltas to exercise fragment reassembly), answer the SDK's follow-up tool-result request (role:'tool' message) with turn-ending completion text, and remove the classic XML-era EDIT_REQUEST/REPLACE_REQUEST responses.

diff.test.ts now covers the full approval flow: approval ask row -> Save -> editor tool writes the file -> completion text, verifying the edit on disk and restoring the git-tracked fixture afterwards. The old 'test.ts: Original <-> Cline's Changes' diff-tab assertions are unreachable under the SDK executor architecture (the editor executor writes via Node fs and does not route through DiffViewProvider); this behavioral difference is documented in the test file.

* test(vscode): address review feedback on diff.test.ts e2e

- Scope the mock server's tool-result follow-up detection to edit_request conversations so tool results from other (future) scenarios don't mis-route to EDIT_REQUEST_COMPLETE.

- Move the fixture readFileSync inside the try block and guard the finally restore, so a failed read doesn't bypass cleanup attribution or write undefined back to the fixture.

* docs(vscode): rephrase diff e2e comments to describe current behavior

Comments described historical behavior (XML-style tool-call parsing that predates the SDK runtime), which is confusing to readers of the current code. Rephrase them to describe the code as it exists now.

* test(vscode): rename diff.test.ts to file-edit.test.ts and drop duplicated preamble

The test no longer touches a diff editor (the SDK editor executor writes files directly after approval), so the 'Diff Editor' name was misleading. Rename the file and describe block to match what it asserts: the file-edit approval flow.

Drop the first half of the test (send hello, wait, New Task, check history), which duplicated chat.test.ts, and the mock server's 500ms delay that existed only to support an 'API Request...' visibility assertion that no longer exists.
2026-06-23 17:05:32 +09:00
Robin Newhouse 57db641f75 fix(vscode): stabilize SDK e2e login flow (#11441) 2026-06-23 17:05:32 +09:00
Dominic Cooney eaee1940a5 fix(vscode): persist skill disable to SKILL.md frontmatter so the model honors it (ENG-1995) (#11294)
* fix(vscode): persist skill disable to SKILL.md frontmatter so the model honors it (ENG-1995)

The VS Code skill toggle only updated extension state (globalSkillsToggles /
localSkillsToggles), but the SDK builds the model's skill list and the `skills`
tool from each SKILL.md's frontmatter `disabled` flag. As a result, disabling a
skill in the sidebar left it fully available to the model, including in new
tasks.

toggleSkill now also writes the `disabled` flag to the skill's SKILL.md
frontmatter (no-op for remote skills, which have no backing file), via new
helpers updateSkillMarkdownDisabledState / setSkillDisabledInFrontmatter in
skills.ts. Adds unit tests for both helpers.

* fix(vscode): don't rewrite skills with malformed frontmatter (ENG-1995)

parseYamlFrontmatter fails open on invalid YAML, returning the full original
document as the body. updateSkillMarkdownDisabledState would then prepend a
second `---` block on a disable, corrupting the file. Bail out and leave the
file untouched when frontmatter fails to parse. Adds tests for the malformed
disable/enable cases.

Addresses Greptile review feedback on #11294.

* test(vscode): assert malformed-skill fixture is actually invalid YAML (ENG-1995)

Add a guard test that parseYamlFrontmatter reports hadFrontmatter and a
parseError for the shared malformed fixture, so the two "leave file untouched"
tests can't silently pass via a different code path if the fixture ever became
valid YAML.

Addresses Greptile review feedback on #11294.

* fix(vscode): resolve @cline/shared/storage subpath in mocha unit-test compile

The CommonJS mocha unit-test runner uses classic "node" moduleResolution,
which does not read the `exports` subpath maps in @cline/* package
manifests, so `@cline/shared/storage` (imported by
src/sdk/telemetry-settings-sync.ts) failed with TS2307 when test files
transitively reach the SDK adapter. Mirror the explicit paths mapping
already added to tsconfig.test.json for the integration-test compile.

* fix(vscode): restore E2E mock auth in SDK auth service so e2e tests can sign in

The SDK migration replaced classic AuthService (which swapped in
AuthServiceMock under E2E_TEST) with sdk/auth-service.ts, losing the
mock path. "Login to Cline" then invoked the real SDK OAuth flow and
opened a native browser dialog the Playwright tests cannot interact
with, so helper.signin() never authenticated and chat.test.ts +
diff.test.ts failed on every platform (the failures also reproduce on
the base branch).

- auth-service.ts: under E2E_TEST=true (and CLINE_ENVIRONMENT=local),
  exchange the well-known test code with the local mock API server and
  persist credentials to providers.json — no browser. Replaces classic
  AuthServiceMock (see origin/main src/services/auth/AuthServiceMock.ts).
- chat.test.ts/diff.test.ts: wait for the mock turn to complete before
  clicking New Task; SDK history is persisted at turn end, so navigating
  mid-turn races the write and Recent never shows.
- diff.test.ts: the footer Start New Task button only appears for
  attempt_completion turns under SDK TurnState; use the header New Task
  button like chat.test.ts.
2026-06-23 17:05:32 +09:00
Saoud Rizwan ef286c2427 fix(vscode): auto-continue the task when switching from plan to act (#11401)
* fix(vscode): enforce stop-before-start ordering for same-id session restarts

The app reuses the taskId as the sessionId whenever it replaces or
resumes a session (mode/MCP rebuilds, follow-up resume, history
restore), but the old session's stop ran fire-and-forget, and core
cleanup is keyed by sessionId across multiple awaits. A stop still in
flight when the same-id replacement started could tear down the live
successor: late sessions-map deletes, a late 'ended' emission, or a
stalled status write landing on the replacement.

Adopt the sequencing invariant the CLI has always used: never start a
same-id session while its stop is in flight. SdkSessionLifecycle tracks
in-flight stops in a pendingStops map keyed by sessionId, and
startNewSession awaits the pending stop for a reused id before starting
(with a log line so a wedged stop is diagnosable). Fresh-id starts
never wait. fireAndForgetSend additionally captures the ActiveSession
by object identity at send time so a send settling after a same-id
replacement cannot flip the successor's run state.

* fix(vscode): auto-continue the task when switching from plan to act

In plan mode, the model's switch_to_act_mode tool call flipped the toggle
but ended the run as aborted: the beforeModel stop hook fired after
turn-started, leaving a dangling api_req_started spinner rendered as
'API Request Cancelled', and nothing continued the task after the
act-mode rebuild. Manually toggling after a presented plan had the same
dead end.

The tool now declares lifecycle.completesRun so the run ends cleanly
after the tool result, and the queued mode change rebuilds the session
and auto-continues with a hidden continuation prompt. A manual plan to
act toggle auto-continues only when the agent is idle after presenting
its plan (not running and awaiting_followup; a pending ask_question
blocks mid-run so it cannot false-positive). Composer content rides
along: typed text becomes the continuation, attachments are forwarded
and echoed, attachment-only toggles count as consumed. The RPC reports
consumption only after the send was actually handed to the session, and
the webview then clears only the exact submitted content, so failures
and racing input never lose composer state. Failures before the send
undo the optimistic running flip, report an error phase, and roll the
mode back when the session was never replaced.

Hidden prompts (the act continuation and the pre-existing task
resumption prompt) shifted editMessageAndRegenerate's visible-to-SDK
user message ordinal mapping; the new sdk-user-message-mapping module
skips them in their persisted user_input-wrapped shape, counts
attachment-only messages (which have visible bubbles), ignores
tool-result rows, and attachment-only resumes now echo a bubble to keep
both transcripts aligned. Follow-ups sent during a rebuild wait on
waitForPendingRebuild instead of resuming a parallel session that the
rebuild would kill.

The plan-mode system prompt and tool description require explicit user
approval in a message sent after the plan was presented, preventing the
model from self-escalating to act mode.

* fix(vscode): move the turn phase to error when a task resume fails

askResponse optimistically sets the turn phase to streaming before
delegating to the followup coordinator, but the coordinator's resume
catch only posted an error row, leaving the footer stuck on
Thinking/Cancel. Resume failures (auth errors, session start errors)
now report back via onResumeFailed so the controller can set the phase
to error.
2026-06-23 17:05:32 +09:00
Saoud Rizwan 3122150d5e fix(webview): use consistent reasoning selector component in extension provider settings (#11399)
* fix(webview): use themed components and reasoning selector in generic provider settings

The catalog-backed GenericProviderSettings path (deepseek, gemini, mistral,
and other migrated providers) rendered its model picker with raw unstyled
HTML select/input/button elements, unlike every other provider which uses
the VS Code webview-ui-toolkit components. Swap ModelPickerWithManualEntry
to VSCodeDropdown/VSCodeOption/VSCodeTextField/VSCodeButton, reusing the
DropdownContainer and re-init key workaround from common/ModelSelector.

Also render ReasoningEffortSelector in GenericProviderSettings when the
selected model's catalog info has supportsReasoning, persisting the effort
through the provider config reasoning patch, matching ClineModelPicker.
This is driven by the catalog capability flag rather than provider id.

* fix(webview): re-sync custom model id field after async config hydration

The controlled customModelId state was initialized once at mount, but the
provider config and model catalog both hydrate asynchronously, so the lazy
initializer could capture a placeholder value and leave the custom model
text field stale once the committed selection loaded. Sync the field via an
effect keyed on the committed model id and its in-list status, depending on
derived values rather than the models object whose identity can change
every render while the catalog loads.
2026-06-23 17:05:32 +09:00
Robin Newhouse 3a0f656337 fix(vscode): expand remote workflow/skill slash commands before send ENG-2036 (#11388)
* fix(vscode): expand remote workflow/skill slash commands before send

The SDK-backed extension sent `/workflow` text to the model verbatim, so
remote-config workflows never ran. Expansion is host-driven (the agent loop
never auto-expands), and the controller's pre-send path did none — matching
the CLI's `buildUserInputMessage`, resolve slash commands via a
controller-owned UserInstructionConfigService that watches the workspace
(including `.cline/remote-config/`), refreshed after each remote-config sync.

Fixes ENG-2036.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(vscode): guard instruction watcher against post-dispose race

Reject in ensureUserInstructionService when the controller is already
disposed so a slash-command resolution that yielded across dispose() can't
resurrect a file watcher that nothing will stop. Also log the post-expansion
length handed to parseMentions. Addresses Greptile review.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 17:05:32 +09:00
Max Paulus 🥪 03186c385c include optional deps so that CI passes 2026-06-23 17:05:32 +09:00
Max Paulus 🥪 bee5c1343d fix broken tests 2026-06-23 17:05:32 +09:00
Max Paulus 🥪 3c27b63013 bump sdk version 2026-06-23 17:05:31 +09:00
Max Paulus 🥪 e3f36e5d3c add vertex support to extension 2026-06-23 17:05:31 +09:00
Mikołaj Kondratek 7edd8deb3c fix(sdk): make model-not-found API errors actionable in the webview (#11378)
When a provider returns a model-not-found error (e.g. Anthropic's HTTP 404
for a retired model such as claude-3-haiku-20240307), the SDK strips the
status and delivers only the terse body, which collapses to the bare label
"model: <id>". reshapeErrorForWebview fell through to returning that raw
string, so ErrorRow rendered a label-like fragment in red with no hint that
the model is gone or how to recover.

Detect these in the plain-text branch of reshapeErrorForWebview and rewrite
them into a sentence that names the model and tells the user to switch models
in API Configuration settings, then retry. The model switch is framed as a
precondition rather than a parallel option so users don't loop on Retry.

Detection is text-based because the HTTP status is unavailable at this point.
The keyword match is anchored to the word "model" with a not-found signal in
the same sentence, so unrelated errors that merely mention a model (plan
gating, deprecated features) are left untouched. Adds tests for the bare
label form, a generic "does not exist" form, and two negative cases (plan
gating and an auth error mentioning a model) that must pass through unchanged.
2026-06-23 17:05:31 +09:00
Max Paulus 🥪 4d217920e2 fix telemtry opt flag migration 2026-06-23 17:05:31 +09:00
Dominic Cooney 905b7edfce fix(vscode): resolve @cline/shared/storage subpath in test compile + vitest
The CommonJS integration-test tsconfig (moduleResolution: node) and the
vitest config did not resolve the @cline/shared/storage exports subpath
imported by src/sdk/SdkController.ts, breaking 'compile-tests' (TS2307)
and 3 vitest SDK suites. Add explicit path/alias mappings to the built
dist so both resolve without changing module emit. Compile-time/test-only;
emitted JS still uses the real package specifier.
2026-06-23 17:05:31 +09:00
Max Paulus 🥪 074b55625a migrate telemetry value in extension 2026-06-23 17:05:31 +09:00
Max Paulus 🥪 1ac9aa3742 bump sdk version 2026-06-23 17:05:31 +09:00
Max Paulus 🥪 7481fe8e4c fix claude-code setting loading/persistence 2026-06-23 17:05:31 +09:00
Max Paulus 🥪 ee6ee3f10f fix task history delete 2026-06-23 17:05:31 +09:00
Max Paulus 🥪 a0c9503229 fix model selector not showing most up to date model in providers.json 2026-06-23 17:05:31 +09:00
Max Paulus 🥪 fcf55ce707 fix ui test 2026-06-23 17:05:30 +09:00
Max Paulus 🥪 b3f5e03285 fix ci checks 2026-06-23 17:05:30 +09:00
Robin Newhouse e36f3666f9 refactor(vscode): remove MCP marketplace ENG-1591 (#11217)
* refactor(vscode): remove MCP marketplace

* test(vscode): clarify MCP marketplace removal test

* docs: update MCP server controls docs
2026-06-23 17:05:30 +09:00
Max Paulus 🥪 badb98a2f3 fix anthropic provider settings persistence 2026-06-23 17:05:30 +09:00
Max Paulus 🥪 bc56387ca7 remove baseUrl from providers.json when unchecking box in ui 2026-06-23 17:05:30 +09:00
Max Paulus 🥪 1bf6a6e661 fix ollama and lmtudio settings persistence 2026-06-23 17:05:30 +09:00
Max Paulus 🥪 0d56fb813d fix openrouter apikey persist to providers.json 2026-06-23 17:05:30 +09:00
Max Paulus 🥪 89f50f5d3f fix vscodelm provider settings persist 2026-06-23 17:05:30 +09:00
Max Paulus 🥪 10e81e1f11 persist bedrock settings to providers.json 2026-06-23 17:05:30 +09:00
Max Paulus 🥪 40aed16f6f don't block user input when hasNoUsableProvider == true 2026-06-23 17:05:30 +09:00
Mikołaj Kondratek b482b5aa26 fix(bedrock): treat profile/IAM/credential-chain auth as a usable provider (#11313)
The inline "Sign in to Cline or add an API key" gate appeared and disabled
chat for Amazon Bedrock users who configured AWS Credentials (access key +
secret), an AWS profile, or relied on the default AWS credential chain, even
though the provider was fully usable (issue #11270).

hasUsableProvider() decided Bedrock usability solely via resolveApiKey(),
which maps bedrock -> awsBedrockApiKey. Bedrock's three non-API-key auth
modes leave that field empty, so buildBedrockProviderConfig() would build a
working session while the gate reported the provider unusable. The Cline
login state is irrelevant here: the gate is computed for the active-mode
provider, and the "Sign in to Cline" button is just one of two generic
remedies, which is what made the symptom look like a logged-out state.

Add a Bedrock branch that classifies usability per auth mode, reusing
resolveBedrockAuthentication() so the gate and the session builder agree on
what each mode means:
- api-key: usable only when awsBedrockApiKey is non-blank (unchanged, now
  also rejects whitespace-only keys)
- profile / iam / default credential chain: usable, deferring credential
  resolution to request time (mirrors buildBedrockProviderConfig and the
  existing keyless-provider philosophy)

Manually verified on a real setup across all four auth modes: pre-fix the
gate blocked chat for access-key and profile auth; post-fix the gate clears
and chat works. API-key mode was never gated incorrectly.

Tests: add Bedrock coverage for every auth mode, including api-key with a
blank and with an unset key (both not usable), the SigV4 repro, profile
(explicit/inferred/awsUseProfile), the bare credential-chain config, and
plan-mode resolution plus plan/act isolation.
2026-06-23 17:05:29 +09:00
Ara 0a9542fd61 Fix SDK task size in delete tooltip (#11277)
* fix: show SDK task size in delete tooltip

* fix: address SDK task size review feedback

* fix: simplify SDK task size caching
2026-06-23 17:05:29 +09:00
Max Paulus 🥪 204f17ec08 remove unused code/files
fix broken webview build

remove unused code/files

fix broken webview build
2026-06-23 17:05:29 +09:00
Max Paulus 🥪 4845845db8 delete unused files 2026-06-23 17:04:56 +09:00
Max Paulus 🥪 f1d4908e47 Add edit and regenerate for VS Code chat messages
Allow user feedback messages in the VS Code extension to be edited inline and regenerated from that point. Adds a TaskService RPC, truncates persisted SDK history before the selected visible user prompt, and starts a new session with the edited prompt. Also ensures the regenerated active task appears in extension history while SDK history catches up.
2026-06-23 17:04:56 +09:00
Max Paulus 🥪 e5b7b576ce fix webview-ui tests 2026-06-23 17:04:56 +09:00
Max Paulus 🥪 d556af22b8 show model list if possible for openai compatible 2026-06-23 17:04:55 +09:00
Max Paulus 🥪 9b4a8f3ace fix onboarding model selection not persisting 2026-06-23 17:04:55 +09:00
Max Paulus 🥪 779f6b10c3 remove provider-specific views and just use genericprovidersettings.tsx 2026-06-23 17:04:55 +09:00
Max Paulus 🥪 16284eb128 dry up duplicate code and create useProviderModelSelection 2026-06-23 17:04:55 +09:00
Max Paulus 🥪 983fbd1027 dry up provider api key logic 2026-06-23 17:04:55 +09:00
Max Paulus 🥪 74e04061f3 dry up some duplicate code 2026-06-23 17:04:55 +09:00
Max Paulus 🥪 e19abb90b7 fix onboarding models 2026-06-23 17:04:55 +09:00
Max Paulus 🥪 6dde25f2b0 fix failing biome/lint 2026-06-23 17:04:55 +09:00
Mikołaj Kondratek 5dfe6615c5 Remove unused import 2026-06-23 17:03:45 +09:00
Mikołaj Kondratek 8b9b46de4c fix(sdk): drop dead autoContinue branch in mode rebuild
cf25cd66a ("make extension plan mode more similar to CLI") removed the
file-level ACT_MODE_CONTINUATION_PROMPT constant and stopped passing the
autoContinue / continuationPrompt options when rebuilding a session for
a mode change, but left the corresponding block inside
rebuildSessionForMode in place. The block still references the deleted
constant, so tsc fails on the SDK migration branch with TS2304: Cannot
find name "ACT_MODE_CONTINUATION_PROMPT".

No caller passes options to rebuildSessionForMode anymore, so the block
is dead. Drop the block and narrow the signature to take only newMode.
Existing tests already invoke rebuildSessionForMode(<mode>) with no
second argument and assert that fireAndForgetSend is not called on a
mode rebuild, so they keep passing.
2026-06-23 17:03:44 +09:00
Mikołaj Kondratek 6949d21569 fix(terminal): capture standalone terminal output on Windows and harden PowerShell command handling (#11133)
* fix(terminal): surface standalone terminal spawn diagnostics

Add Logger calls at every chokepoint of the standalone terminal pipeline
so the (currently silent) failure modes around JetBrains-hosted
cline-core become debuggable from cline-core-service.log.

Lines added, all using the existing Logger facility (no new
dependencies, no behavioral changes):

* StandaloneTerminalProcess.run() now logs:
  - `[StandaloneTerminalProcess] run() entered: shell=… cwd=… args=…`
    on entry, before the try block;
  - `[StandaloneTerminalProcess] spawned pid=… for shell=…` right
    after child_process.spawn returns;
  - `[StandaloneTerminalProcess] close: code=… signal=… fullOutputLen=…`
    inside the `close` handler (the `fullOutputLen` reveals when the
    child exits 0 with empty pipes — the symptom in issue #10948);
  - `[StandaloneTerminalProcess] child error: …` in the `error`
    handler;
  - `[StandaloneTerminalProcess] spawn threw synchronously: …` in
    the outer catch.

* StandaloneTerminalManager.runCommand() now logs entry
  (`[StandaloneTerminalManager] runCommand terminalId=…: <cmd>`) and
  attaches a `.catch` to the previously fire-and-forget
  `process.run(…)` Promise so an unhandled rejection surfaces as
  `[StandaloneTerminalManager] process.run rejected for terminal …`
  instead of disappearing.

* CommandExecutor.execute() extends the existing "Executing command
  in … terminal" line with `mode=<terminalExecutionMode>` and
  `managerCtor=<manager.constructor.name>`, so it's possible to
  confirm whether the `vscodeTerminal` path is in fact backed by a
  `StandaloneTerminalManager` on JetBrains (it is — see
  notes/issue-10948-…md).

* CommandOrchestrator.orchestrateCommandExecution() logs the
  `process.once("completed")` event with `exitCode`/`signal`/
  `terminalType`, the "resolved completed" return branch with the
  line/byte totals, and emits a `WARN` on the silent "still running"
  fall-through. The last one matters because the original repro
  reported "Command executed successfully (exit code 0)" with empty
  output — the WARN makes that branch loud the next time it fires.

These logs are what made the two distinct bugs in #10948 visible
(see the 2026-05-28 update in
notes/issue-10948-terminal-output-investigation-2026-05-27.md). They
stay in to keep the next regression debuggable.

Refs: cline/cline#10948

* fix(terminal): keep Windows child stdio attached to parent pipes

The non-cmd Windows branch in StandaloneTerminalProcess.run() spawned
the shell (powershell.exe in practice) with `detached: true` and no
`windowsHide`. When cline-core is launched by the JetBrains plugin it
has no console of its own, so Windows CreateProcess allocates a NEW
console for the detached child and the child's stdio routes to that
new console instead of the pipe handles the parent created. From the
parent's point of view the pipes immediately EOF, `close` fires with
`code=0`, and `fullOutput` is 0 bytes — exactly the symptom reported
in cline/cline#10948 ("Command executed successfully (exit code 0)"
with no output and no filesystem effect).

This bug applies to every command the agent runs through the
standalone terminal path on Windows, not just the
double-wrapped-PowerShell cases (verified by re-running a clean
`dir <file>` after the diagnostics from the previous commit landed:
`run() entered` and `spawned pid=<num>` both fired, then `close: code=0
fullOutputLen=0`).

Fix:

* `detached: process.platform !== "win32"` — keep the existing
  POSIX behavior (a separate process group helps `tree-kill`), but
  drop it on Windows where `tree-kill` walks the PID tree with
  `taskkill /T` and doesn't need a process group.
* `windowsHide: true` — matches every other `child_process.spawn`
  call site in cline-core (git, MCP, hooks, browser) and flips on
  `CREATE_NO_WINDOW`, keeping the child attached to our pipes
  without popping a console window.

Verified on Windows 11 + IntelliJ IDEA 2026.1 + Cline plugin
1.1.59-Internal: `dir <path>`-style probes now produce a non-zero
`fullOutputLen` in the close log, and the captured output bytes
match what would have been visible interactively. PowerShell
double-wrapping (the other half of #10948) is handled in a
follow-up commit.

Refs: cline/cline#10948

* fix(terminal): harden PowerShell command wrapping for standalone shell

`StandaloneTerminalProcess.getShellArgs()` blindly wrapped every
PowerShell command as `["-Command", command]`. That has two
end-user-visible failure modes on Windows, both observed in
cline/cline#10948:

1. The agent's `run_commands` tool call sometimes arrives already
   prefixed with `powershell -Command "…"`. We then spawned
   `powershell.exe -Command 'powershell -Command "…"'`, and the
   outer shell shredded the inner single/double-quote pairs while
   re-parsing its `-Command` argument. The inner pwsh saw
   quote-empty `Test-Path` calls, fell through to the `else` branch
   and reported "File not found" — to ITS stdout, which the outer
   inherited but the file deletion the LLM intended never ran.
2. The user's `$PROFILE` script ran on every spawn, leaking
   non-deterministic noise (e.g.
   `%windir%\System32\REG.exe : The module '%windir%' could not be
   loaded`) into the captured output and confusing the agent.
3. Bonus: the POSIX branch used `["-l", "-c", command]`. The `-l`
   re-sources login files on every command, which is slow and lets
   greeter scripts leak into output.
4. Bonus: the cmd branch used `["/c", command]`. `/d` skips
   AutoRun, `/s` makes the embedded-quote handling deterministic.

Fix:

* PowerShell branch returns
  `["-NoProfile", "-NonInteractive", "-Command", unwrap(command)]`.
  `-NoProfile` suppresses (1) the spurious profile noise that
  contaminated the captured output, and `-NonInteractive` ensures
  the child doesn't deadlock waiting on a prompt no one will answer.
* `unwrapPowerShell(command)` strips a leading
  `powershell|pwsh [.exe] -Command|-c "…"` (or single-quoted)
  wrapper that the LLM sometimes emits, fixing the double-pass
  argument-quoting destruction. If the command does not match the
  exact wrapper shape it is returned verbatim — worst case is "no
  change", preserving pre-fix behavior.
* cmd branch returns `["/d", "/s", "/c", command]`, matching the
  canonical helper in cline/sdk/packages/shared/src/parse/shell.ts.
* POSIX branch returns `["-c", command]`, dropping the unhelpful
  `-l`. Also matches the SDK helper.

Verified on Windows 11 + IntelliJ IDEA 2026.1 + Cline plugin
1.1.59-Internal in combination with the previous "keep Windows
child stdio attached" commit: `Remove-Item CHANGELOG.md` now
deletes the file, the agent's verification `Get-ChildItem CHANGELOG*`
returns nothing, and the profile-load REG.exe error no longer leaks
into captured output.

Refs: cline/cline#10948

* refactor(terminal): tone down standalone terminal diagnostics

The diagnostics added while chasing #10948 were intentionally loud so the
two bugs were visible. Now that the fixes are in, reduce them to a normal
operating posture:

* Demote fine-grained traces to `debug`: the per-spawn `spawning …` and
  `spawned pid=…` lines, `StandaloneTerminalManager.runCommand`, and the
  orchestrator's `resolved completed` summary.
* Drop the orchestrator's `completed event` line entirely — the
  `resolved completed` debug line already carries exit code, signal, and
  line/byte totals.
* Stop echoing the full command in the manager line and stop echoing the
  args vector in the spawn line. The command is still logged once at
  `info` by CommandExecutor (unchanged, pre-existing), so we go back from
  three command echoes to one. Commands routinely embed secrets
  (Authorization headers, tokens), so fewer copies on disk is better.

Kept loud on purpose:

* `info` on `close: code=… fullOutputLen=…` — the single line that proves
  the Windows stdio-capture fix and the most useful per-command signal.
* `warn` on `resolved without completion event` — the silent-success
  canary for the #10948 failure mode.
* `error` on child error / synchronous spawn failure / unhandled
  process.run rejection.

Refs: cline/cline#10948

* fix(terminal): tighten PowerShell unwrap regex and extract to a pure module

Two review follow-ups for the #10948 shell-arg handling:

1. The wrapper-strip regex used a greedy `([\s\S]*)` body, so a command
   like `powershell -Command "foo" "bar"` would match with the body
   captured as `foo" "bar`, silently rewriting a command into something
   different. Replace the body with a tempered match `((?:(?!\1).)*)`
   that cannot contain the captured delimiter, so anything other than
   exactly one quoted token is returned verbatim. Worst case is now
   "no change" rather than an incorrect rewrite. The legitimate
   double-wrapped case from #10948 (outer ", inner ') still unwraps.

2. `getShellArgs` and `unwrapPowerShell` were private methods on
   StandaloneTerminalProcess, untestable without spawning a process.
   Move them to a pure `shellArgs.ts` module. `getShellArgs` now takes
   an injectable `platform` (defaulting to `process.platform`) purely so
   the win32-vs-posix branch is testable; behavior is unchanged. This
   also gives us a single local seam to later consolidate onto the
   canonical `@cline/shared` helper (tracked as a follow-up).

No behavioral change beyond the regex correctness fix.

Refs: cline/cline#10948

* test(terminal): cover shell-arg construction and PowerShell unwrap

Add mocha unit tests (matching the repo's node:assert/strict + __tests__/
convention so the existing mocharc spec globs pick them up) for the newly
extracted shellArgs module:

* unwrapPowerShell: double-quote and single-quote wrappers, powershell.exe
  -c form, the #10948 nested-quote repro (inner quotes preserved),
  non-wrapped passthrough, and the two regressions the tightened regex
  must reject (`… "foo" "bar"` and a command that merely mentions
  powershell mid-string).
* getShellArgs: PowerShell -> -NoProfile -NonInteractive -Command (with
  unwrap), cmd -> /d /s /c, POSIX -> -c. The injectable platform arg lets
  these run on any CI host.

This closes the M1 review finding (the regex was the riskiest line in the
change and had zero coverage) and exercises the cmd/POSIX flag changes
called out in M2.

Refs: cline/cline#10948

* docs(terminal): drop issue references and clarify windowsHide comment

Remove inline issue-number references from source comments and a test
name; that context belongs in the commit history, not the code. Also add
a one-line note that windowsHide is a no-op on non-Windows platforms,
since it is set unconditionally while the surrounding comment is
Windows-specific.

No behavior change.

* refactor(terminal): drop warn on the non-completion return path

The orchestrator's final fall-through return is a normal, expected path:
the process resolved via `continue` without a `completed` event (e.g. a
terminal mode without shell integration, or proceed-while-running flows).
Logging it at `warn` cries wolf on healthy runs, so remove it. The
genuine failure mode this was meant to catch surfaces through the
`close`/error logs and the result string itself.

* fix(terminal): address review feedback on standalone spawn paths

Three follow-ups from code review:

* StandaloneTerminalManager.runCommand: the unawaited process.run()
  .catch only logged. run() emits "error" for failures it catches, but a
  rejection escaping without an "error" event would leave the outer
  promise (resolved via the "continue"/"error" events) pending forever,
  stalling the caller. Re-emit "error" from the catch so both paths stay
  consistent. Cannot trigger today (no await outside run()'s try/catch)
  but the guard exists precisely for future rejections.

* shellArgs POSIX branch: document that dropping the login flag (`-l`)
  is intentional and relies on the child inheriting the parent's PATH via
  process.env, with a note that a GUI-launched IDE without a login PATH
  is the edge case to watch.

* StandaloneTerminalProcess cmd.exe branch: add windowsHide:true. The
  console-allocation/window-pop problem is not exclusive to the non-cmd
  branch; a console-less parent could pop a window for cmd.exe too.
  No-op on non-Windows.
2026-06-23 17:03:44 +09:00
Ara 9b2e6f62ec Remove Explain Changes feature (#11278)
* chore(vscode): remove explain changes entry points

* chore(vscode): remove explain changes feature
2026-06-23 17:03:44 +09:00
Max Paulus 🥪 9b720e11a2 make extension plan mode more similar to CLI
- basically, don't auto continue when agent switches to act mode
2026-06-23 17:03:44 +09:00
Max Paulus 🥪 b331f83985 fix zai insufficient credits issue 2026-06-23 17:03:44 +09:00
Max Paulus 🥪 24bc36ebfe fix tool use name sanitization 2026-06-23 17:02:34 +09:00
Max Paulus 🥪 ee77e0ec9f fix broken tsc 2026-06-23 17:02:34 +09:00
Dominic Cooney 4f8eb47850 fix(vscode): exclude vitest src/sdk suites from CommonJS test compile
compile-tests runs 'tsc -p tsconfig.test.json' (module: commonjs) over all
src/**/*.test.ts for the VS Code integration runner. The new src/sdk vitest
suites use top-level 'await import(...)' (after vi.mock), which is invalid
under CommonJS and fails with TS1378. The integration runner never runs
src/sdk anyway (.vscode-test.mjs only globs core/test/utils/shared/
integrations/hosts/services); these run via 'npm run test:vitest'. Exclude
src/sdk/**/*.test.ts from the integration compile.
2026-06-23 17:02:34 +09:00
Dominic Cooney 953a9d2626 fix(vscode): restore biome --config-path so lint resolves apps/vscode/biome.jsonc
The rebase dropped '--config-path ./biome.jsonc' from the lint/format/
postprotos scripts and removed the '!!**/.vscode-test' ignore from
biome.jsonc. Without the explicit config path, biome auto-discovered the
root biome.json instead of apps/vscode/biome.jsonc, applying the wrong
rule severities (449 errors at error level for rules that are off/info in
the nested config). Restore both to match origin/main and apply the
pending buf format fix to models.proto.
2026-06-23 17:02:34 +09:00
Dominic Cooney ac2692a9c0 fix(vscode): show running state for in-progress commands
The command row reflects an executing state while a command runs. The
message translator includes the command-output marker on the running
command row so the webview renders it as executing; the row is finalized
with output and a completed flag when the command ends.

Also remove the unused onChange parameter from the foreground run_commands
path: the SDK runtime does not pass it, so it had no effect. Foreground
command output is surfaced to the chat at completion, not incrementally.

Fixes CLINE-2298 and CLINE-2162
2026-06-23 17:02:34 +09:00
Dominic Cooney 835e303ef7 fix(vscode): re-enable approval buttons for consecutive asks
The footer Approve/Reject buttons stayed disabled when a second consecutive
approval ask arrived. The button configs are shared singletons (e.g.
BUTTON_CONFIGS.tool_approve), so two identical asks return the same object
reference and the effect that reset the processing latch never re-ran.

Key the processing latch on the ask identity (anchored turn timestamp plus the
button labels) rather than the config object reference, using a ref-based latch
so each new ask re-enables the buttons. Adds a regression test.

Test plan:

1. Ask the agent to generate two requests to ls /tmp at once

2. Approve (or reject) the first request

3. Check that the buttons for the second request are enabled
2026-06-23 17:02:34 +09:00
Dominic Cooney a3a0a930c2 feat(vscode): add the VS Code Language Model (vscode-lm) provider
Run Cline inference through the VS Code Language Model API (vscode.lm), enabling
models contributed by any extension that registers a language model chat
provider with VS Code. GitHub Copilot is the most common such vendor, but the
implementation is vendor-agnostic — it selects models via
vscode.lm.selectChatModels and has no Copilot-specific logic.

- VsCodeLmHandler implements the Cline SDK ApiHandler and is registered with the
  SDK handler registry; the model selector travels as a vendor/family[/version/id]
  string in modelId and is parsed back here. Selector segments are
  percent-encoded so values containing slashes round-trip intact.
- Native tool calling: tool definitions are passed to sendRequest and tool calls
  are surfaced as tool-call chunks; tool results round-trip as
  LanguageModelToolResultPart, with structured tool output serialized to text and
  a trailing user message appended when a turn ends on tool results so models can
  read the output.
- Gated to VS Code: registration is conditioned on the vscode.lm API being
  present, and the provider is hidden in the UI on hosts without it (JetBrains).

Depends on @cline/{shared,llms,agents,core} 0.0.42-nightly.1780514867, the first
published SDK build with the custom-registered-handler routing this provider
needs.
2026-06-23 17:02:34 +09:00
Ara d073efe5cd Fix approval chat replies rendering as tool errors (#11246)
* fix(vscode): route approval chat replies as user feedback

* fix(vscode): suppress approval reply denial errors

* fix(vscode): hide rejected approval tool failures

* chore(vscode): clarify denied approval suppression helper
2026-06-23 17:02:34 +09:00
Max Paulus 🥪 9ce4abd2ec Persist OpenRouter provider config via catalog hook 2026-06-23 17:02:33 +09:00
Max Paulus 🥪 30f90056d6 persist openai-codex provider model settings
- also don't show a sign in button if openai-codex is the only provider
2026-06-23 17:02:33 +09:00
Max Paulus 🥪 09a6e258f7 Persist Cline model selections to provider config 2026-06-23 17:02:33 +09:00
Dominic Cooney 4922c7694b fix(vscode): declare missing direct deps @grpc/proto-loader and @opentelemetry/api-logs
Both packages are imported directly from source but were never declared in
apps/vscode/package.json, so they only resolved transitively. On a clean
install this broke:

- @grpc/proto-loader — imported by scripts/proto-utils.mjs,
  src/standalone/utils.ts and src/standalone/hostbridge-client.ts; its absence
  made `npm run protos` (and therefore the whole build) fail on a fresh checkout.
- @opentelemetry/api-logs — imported by the OpenTelemetry telemetry providers;
  its absence produced TS2307 "Cannot find module" errors under tsc.

Versions are pinned to align with the existing dependency families already
declared in this package (@grpc/grpc-js ^1.9.x → proto-loader ^0.7.13;
the @opentelemetry/* 0.56.x line → api-logs ^0.56.0). The npm and bun
lockfiles are updated accordingly (the api-logs change also dedupes several
previously-nested copies to a single hoisted entry).
2026-06-23 17:02:33 +09:00
Max Paulus 🥪 8b7e8d1c16 show legacy task history that is not saved in the ~/.cline folder 2026-06-23 17:02:16 +09:00
Max Paulus 🥪 ce446f108d add migration telemetry 2026-06-23 17:02:16 +09:00
Ara 61d0e10a26 fix(vscode): reuse approved tool rows (#11213)
* fix(vscode): reuse approved tool rows

* fix(vscode): clear stale approved tool rows
2026-06-23 17:02:16 +09:00
Dominic Cooney e6ff8e8802 sdk migration: squashed pre-2026-06-02 work
Omnibus squash of the 10 oldest SDK-migration commits (authored 2026-05-27
through 2026-06-02), collapsed during the 2026-06-09 rebase onto origin/main.

Squashed commits:
- sdk migration: squashed pre-2026-05-27 work
- sdk migration: squashed 06-05-2026 -- instead of listHistory, use host.get(sessionId) instead
- updat gitignore
- fix xai provider
- fix(vscode): forward Bedrock region + AWS auth to the SDK gateway
- fix(vscode): keep in-progress MCP OAuth flow across reconnects
- fix(vscode): wire auto compact into SDK sessions (#11197)
- fix(vscode): compact Codex OAuth before input cap (#11194)
- fix unauthed user flow
- fix(llms): strip Cerebras reasoning history (#11214)
2026-06-23 17:02:15 +09:00
1179 changed files with 55328 additions and 186374 deletions
+2 -2
View File
@@ -41,11 +41,11 @@ fi
# Install project dependencies
echo "Installing dependencies..."
npm run install:all
bun run install:all
# Generate gRPC/protobuf types (required for TypeScript)
echo "Generating proto types..."
npm run protos
bun run protos
echo ""
echo "Session setup complete!"
+55
View File
@@ -0,0 +1,55 @@
# Bun (tooling) and Node (runtime)
This repo uses **bun** for package management and task running, and **Node** as
the execution runtime. Both are correct at the same time; the distinction is the
source of most confusion, so keep it straight before editing scripts, configs,
docs, or comments.
## Use bun for tooling
- `bun install` (never `npm install` / `npm ci`)
- `bun run <script>` (never `npm run <script>`)
- `bunx <bin>` (never `npx <bin>`)
- `bun <file>.ts` to run a TS entrypoint directly (no `ts-node` / `tsx`)
- `bun esbuild.mjs` to drive the build (esbuild/vite are still the bundlers)
- `bun run --parallel ...` for parallel tasks
The root `bun.lock` is the single lockfile for the whole workspace, including
`apps/vscode`, `webview-ui`, and `testing-platform`. There are no per-package npm
lockfiles.
## Node is the runtime — do NOT rewrite these to bun
The build product runs on Node: the VS Code extension host loads
`dist/extension.js` as CommonJS under Node, and the standalone `cline-core` is a
Node process. The following are Node runtime/ABI references and are correct as-is:
| Reference | Why it is Node |
|-----------|----------------|
| esbuild `platform: "node"` / `target: "node..."` | The bundle targets the Node runtime (extension host, standalone core). |
| `TARGET_NODE_VERSION` (`scripts/package-standalone.mjs`) | Pins the Node ABI of the bundled standalone runtime (matches the JetBrains-packaged Node). |
| `prebuild-install --target=<node version>` | Downloads native `.node` binaries for that Node ABI. |
| `NODE_PATH=... node cline-core.js` | The standalone core is launched by Node, not bun. |
| `node:` import specifiers (e.g. `node:fs`) | Node builtin module scheme; unrelated to tooling. |
| `process.versions.node`, `engines.node`, `@types/node` | Runtime version probe / declared runtime / its types. |
| `ELECTRON_RUN_AS_NODE` | VS Code/Electron runs the extension host as Node. |
When a file legitimately uses both bun and node (e.g. `package-standalone.mjs`
does `bun install` but `prebuild-install --target=<node>`), the `node` token is
the runtime/ABI target, not tooling. If unsure, leave it.
## Tests: bun vs the VS Code host
A test file's runner is decided by its import:
- **`import ... from "bun:test"`** → runs under `bun test` (the node-side unit
suites + the SDK/model-catalog suites). `scripts/run-bun-unit-tests.ts`
discovers these by the `bun:test` import and runs one isolated bun process per
file. `build-tests.js` excludes them from the integration compile so the
`bun:test` builtin never reaches Node.
- **`import ... from "mocha"`** → runs under `@vscode/test-cli` in a real VS Code
extension host (Node). These exercise the live `vscode` API and cannot run
under bun.
So a file imports `bun:test` XOR `mocha`. Don't add `bun:test` to a test that
needs the real extension host.
+128
View File
@@ -0,0 +1,128 @@
# Debug Harness
HTTP-controlled debugger for the VSCode extension at `src/dev/debug-harness/server.ts`.
## Quick start
```bash
# Build extension first if needed (protos + esbuild):
bun run protos && IS_DEV=true bun esbuild.mjs
# Launch (skip-build if already built):
bun src/dev/debug-harness/server.ts --skip-build --auto-launch
# In another terminal:
curl localhost:19229/api -d '{"method":"status"}'
```
## Data Isolation
The debugee runs with `CLINE_DIR=~/.cline2` by default, separate from your real `~/.cline`.
This prevents the debugee's logout from logging out the debugger, and vice versa.
Override with `--cline-dir /tmp/test-dir`. Check with `status()``clineDir`.
## Browser Capture & OAuth
The debugee runs with `CLINE_CAPTURE_BROWSER=1`, which intercepts `openExternal()` in
`src/utils/env.ts`. URLs are captured instead of opening a real browser:
- Logged to `$CLINE_DIR/data/debug-captured-urls.jsonl`
- POSTed in real-time to `/captured-url` on the harness server
- Queryable via `oauth.captured_urls`
### OAuth API
- **`oauth.captured_urls`** `{clear?}` — URLs the debugee tried to open
- **`oauth.read_stored_token`** — Check auth token presence in secrets.json
- **`oauth.simulate_callback`** `{path, code?, state?, provider?, token?}` — Build vscode:// callback URI
- **`oauth.read_captured_urls_file`** — Read on-disk JSONL of captured URLs
### OAuth testing flow
For **Cline OAuth** (SDK local callback): The SDK starts a local HTTP server, the auth URL
is captured. To complete: open the captured URL in a real browser (it redirects back to the
SDK's callback server), OR extract the callback port and `curl http://127.0.0.1:PORT/callback?code=...`.
For **MCP/Provider OAuth** (vscode:// URI): The redirect goes to a vscode:// URI.
`oauth.simulate_callback` only *builds* the URI — it does not deliver it, and the ESM
extension host can't `require()` the handler. To actually deliver the callback, call the
debug-only hook via `ext.evaluate` (with `awaitPromise: true`):
`globalThis.__clineHandleUri("vscode://saoudrizwan.claude-dev/...?code=...&state=...")`.
It runs the same `SharedUriHandler.handleUri` as VSCode's real URI handler and exists only
when `CLINE_CAPTURE_BROWSER` is set (the harness always sets it; never ships in prod).
For end-to-end MCP OAuth, get a real `code` from the local MCP OAuth test server
(`bun run dev:mcp-oauth-test-server`).
## Navigating Views — Use Commands, Not Clicks
Don't try to find/click small sidebar icons. Use VSCode commands via command palette.
Registered in `src/registry.ts`:
| Command | View |
|---------|------|
| `cline.accountButtonClicked` | Account / sign-in |
| `cline.historyButtonClicked` | Task history |
| `cline.settingsButtonClicked` | Settings |
| `cline.mcpButtonClicked` | MCP servers |
| `cline.plusButtonClicked` | New task (chat) |
| `cline.worktreesButtonClicked` | Worktrees |
```bash
curl localhost:19229/api -d '{"method":"ui.command_palette","params":{"command":"cline.accountButtonClicked"}}'
```
## Key commands
All via `POST localhost:19229/api` with `{"method":"...", "params":{...}}`:
- **`launch`** / **`shutdown`** — lifecycle
- **`ui.screenshot`** — screenshot to `/tmp/cline-debug/`; returns `{path}`**use `read_file` on the path to examine, do NOT `open` the file** (Preview.app covers the VSCode window)
- **`ui.open_sidebar`** — open the Cline sidebar
- **`ext.set_breakpoint`** `{file, line, condition?}` — breakpoint by source file (sourcemap-resolved)
- **`ext.evaluate`** `{expression, callFrameId?}` — eval in extension host
- **`ext.resume`** / **`ext.step_over`** / **`ext.step_into`** — stepping
- **`ext.call_stack`** — inspect when paused
- **`web.evaluate`** `{expression}` — eval in webview
- **`web.post_message`** `{message}` — send postMessage to extension host via exposed vsCodeApi
- **`wait_for_pause`** `{timeout?}` — block until breakpoint hit
- **`ui.locator`** `{role?, testId?, text?, frame?}` — Playwright locator (auto-retries on stale sidebar frame)
- **`ui.react_input`** `{text, selector?, clear?, submit?}` — set React textarea value via `execCommand('insertText')`; works reliably across multiple tasks
- **`ui.send_message`** `{text, images?, files?, responseType?}` — send chat message bypassing the textarea entirely (via gRPC postMessage)
- **`ui.command_palette`** `{command}` — run VSCode command
## Typical Session
```bash
# 1. Launch
curl localhost:19229/api -d '{"method":"launch","params":{"skipBuild":true}}'
# 2. Open sidebar + dismiss overlays (ALWAYS do this first)
curl localhost:19229/api -d '{"method":"ui.open_sidebar"}'
curl localhost:19229/api -d '{"method":"web.evaluate","params":{"expression":"document.querySelectorAll(\".sr-only\").forEach(el => el.parentElement?.click())"}}'
# 3. Navigate to view
curl localhost:19229/api -d '{"method":"ui.command_palette","params":{"command":"cline.accountButtonClicked"}}'
# 4. Check captured OAuth URLs if testing auth
curl localhost:19229/api -d '{"method":"oauth.captured_urls"}'
# 5. Verify
curl localhost:19229/api -d '{"method":"ui.screenshot"}'
```
## Caveats
- **⚠️ Dismiss promotional overlays FIRST**: On fresh launches, full-screen promo overlays block the sidebar. **Dismiss immediately after `ui.open_sidebar`**, before any other interaction or screenshot. May need to run twice:
```bash
curl localhost:19229/api -d '{"method": "ui.open_sidebar"}'
curl localhost:19229/api -d '{"method": "web.evaluate", "params": {"expression": "document.querySelectorAll(\".sr-only\").forEach(el => el.parentElement?.click())"}}'
```
- **Screenshots — don't open the file**: `ui.screenshot` and `ui.sidebar_screenshot` save PNGs to `/tmp/cline-debug/` and return the `{path}`. Use `read_file` on that path to examine screenshots. Running `open <path>` launches Preview.app on macOS which covers the VSCode window.
- **Scripts count = 0 after launch**: CDP connects after extension host starts, so scripts parsed during startup aren't tracked. Breakpoints still work via sourcemap resolution.
- **Port 9230**: Extension host inspector. If another VSCode instance uses this port, the harness will fail to connect. Kill other debug instances first.
- **macOS only** for now (Playwright Electron launch behavior).
- **Webview CDP**: `connect_webview` may fail depending on Electron version. `web.evaluate` still works via Playwright's `frame.evaluate()` fallback.
- **Sourcemap paths**: esbuild outputs relative paths like `../src/extension.ts` in the sourcemap. The resolver handles this, but if a file isn't found, use `ext.source_files` to see exact paths.
- **OAuth with fake codes**: Browser capture intercepts the URL but doesn't provide a valid auth code. For real OAuth testing, open the captured URL in a browser. For unit testing, mock the token exchange.
See `src/dev/debug-harness/README.md` for full API reference.
+93 -90
View File
@@ -13,11 +13,56 @@ This file is the secret sauce for working effectively in this codebase. It captu
**What NOT to add:** Stuff you can figure out from reading a few files, obvious patterns, or standard practices. This file should be high-signal, not comprehensive.
## Miscellaneous
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `npm run compile`, not `npm run build`).
- The whole repo (including `apps/vscode`) uses **bun** for package management and task running. Emit `bun run X` / `bun install` / `bunx <bin>` / `bun file.ts`, never npm/npx. Node remains the *runtime* (VS Code's extension host and the standalone cline-core are Node), so Node-runtime tokens are legitimate and must not be "fixed" to bun — see @.clinerules/bun-and-node.md for the keep-list vs rewrite-list.
- Avoid provider-specific string matching / hardcoded provider branches when fixing provider/config plumbing. Prefer provider metadata, shared catalog/defaults, explicit protocol/client capabilities, or centralized normalization utilities that apply by data shape rather than `providerId === "..."`. If a provider exception seems necessary, stop and explain why instead of adding ad-hoc string matching.
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `bun run compile`, not `bun run build`).
- When creating PRs, contributors should not create changelog-entry files. Maintainers handle release versioning and changelog curation during the release process.
- When adding new feature flags, see this PR as a reference https://github.com/cline/cline/pull/7566
- Additional instructions about making requests: @.clinerules/network.md
## Searching the Codebase — Avoiding Build Output
Several directories contain build output or generated code that produces
noisy or unusable results with `search_files` / `grep`:
| Directory | What it is | Why it's a problem |
|-----------|-----------|-------------------|
| `out/` | esbuild bundle output | Mirrors `src/` structure as minified JS — every search gets duplicate hits on single-line files |
| `dist/` | Packaged extension | Entire extension bundled into one minified `extension.js` (~1 long line) |
| `dist-standalone/` | Standalone build output | Same minification issue |
| `src/generated/` | Generated protobuf code | Auto-generated from `proto/`; not the source of truth |
| `src/shared/proto/` | Generated proto type defs | Auto-generated from `proto/`; not the source of truth |
| `node_modules/` | Dependencies | Huge, not project source |
### How to skip build output
**`search_files`** — Point at `src/` (not the project root) and use `file_pattern`:
```
search_files(path="src/core", regex="myFunction", file_pattern="*.ts")
```
The `file_pattern` parameter is the most effective filter — e.g. `"*.ts"`,
`"*.tsx"`, `"*.proto"`.
**`grep` directly** — Exclude build dirs and restrict to source extensions:
```bash
grep -rn "myFunction" src/ --include="*.ts" --exclude-dir={out,dist,node_modules,generated}
```
### When you must search minified files
Sometimes you need to verify what got bundled (e.g., checking if a change
made it into the build). Minified files are typically one long line, so
normal `grep` shows the entire file as context. Use these approaches:
- **`grep -oP`** to extract just the match with limited surrounding context:
```bash
grep -oP '.{0,40}myFunction.{0,40}' dist/extension.js
```
- **`read_file`** on files in `out/src/` — these have source maps and are
more readable than `dist/extension.js` (which is the fully bundled output).
- **Source maps** — `out/src/*.js.map` and `dist/extension.js.map` can be
used to trace minified output back to original source locations.
## gRPC/Protobuf Communication
The extension and webview communicate via gRPC-like protocol over VS Code message passing.
@@ -28,7 +73,7 @@ The extension and webview communicate via gRPC-like protocol over VS Code messag
- Naming: Services `PascalCaseService`, RPCs `camelCase`, Messages `PascalCase`
- For streaming responses, use `stream` keyword (see `subscribeToAuthCallback` in `account.proto`)
**Run `npm run protos`** after any proto changes—generates types in:
**Run `bun run protos`** after any proto changes—generates types in:
- `src/shared/proto/` - Shared type definitions
- `src/generated/grpc-js/` - Service implementations
- `src/generated/nice-grpc/` - Promise-based clients
@@ -48,93 +93,6 @@ The extension and webview communicate via gRPC-like protocol over VS Code messag
- `src/core/controller/task/explainChanges.ts` - Handler implementation
- `webview-ui/src/components/chat/ChatRow.tsx` - UI rendering
## Adding a New API Provider
When adding a new provider (e.g., "openai-codex"), you must update the proto conversion layer in THREE places or the provider will silently reset to Anthropic:
1. `proto/cline/models.proto` - Add to the `ApiProvider` enum (e.g., `OPENAI_CODEX = 40;`)
2. `convertApiProviderToProto()` in `src/shared/proto-conversions/models/api-configuration-conversion.ts` - Add case mapping string to proto enum
3. `convertProtoToApiProvider()` in the same file - Add case mapping proto enum back to string
**Why this matters:** Without these, the provider string hits the `default` case and returns `ANTHROPIC`. The webview, provider list, and handler all work fine, but the state silently resets when it round-trips through proto serialization. No error is thrown.
**Other files to update when adding a provider:**
- `src/shared/api.ts` - Add to `ApiProvider` union type, define models
- `src/shared/providers/providers.json` - Add to provider list for dropdown
- `src/core/api/index.ts` - Register handler in `createHandlerForProvider()`
- `webview-ui/src/components/settings/utils/providerUtils.ts` - Add cases in `getModelsForProvider()` and `normalizeApiConfiguration()`
- `webview-ui/src/utils/validate.ts` - Add validation case
- `webview-ui/src/components/settings/ApiOptions.tsx` - Render provider component
## Responses API Providers (OpenAI Codex, OpenAI Native)
Providers using OpenAI's Responses API require native tool calling. XML tools don't work with the Responses API.
**Symptoms of broken native tool calling:**
- Tools get called multiple times (e.g., `ask_followup_question` asks the same question twice)
- Tool arguments get duplicated or malformed
- The model responds but tools aren't recognized
**Root causes to check:**
1. **Provider missing from `isNextGenModelProvider()`** in `src/utils/model-utils.ts`. The native variant matchers (e.g., `native-gpt-5/config.ts`) call this function. If your provider isn't in the list, the matcher returns false and falls back to XML tools.
2. **Model missing `apiFormat: ApiFormat.OPENAI_RESPONSES`** in its model info (`src/shared/api.ts`). This property signals that the model requires native tool calling. The task runner in `src/core/task/index.ts` checks this and forces `enableNativeToolCalls: true` regardless of user settings.
**When adding a new Responses API provider:**
1. Add provider to `isNextGenModelProvider()` list in `src/utils/model-utils.ts`
2. Set `apiFormat: ApiFormat.OPENAI_RESPONSES` on all models that use the Responses API
3. The variant matcher and task runner will handle the rest automatically
## Adding Tools to System Prompt
This is tricky—multiple prompt variants and configs. **Always search for existing similar tools first and follow their pattern.** Look at the full chain from prompt definition → variant configs → handler → UI before implementing.
1. **Add to `ClineDefaultTool` enum** in `src/shared/tools.ts`
2. **Tool definition** in `src/core/prompts/system-prompt/tools/` (create file like `generate_explanation.ts`)
- Define variants for each `ModelFamily` (generic, next-gen, xs, etc.)
- Export variants array (e.g., `export const my_tool_variants = [GENERIC, NATIVE_NEXT_GEN, XS]`)
- **Fallback behavior**: If a variant isn't defined for a model family, `ClineToolSet.getToolByNameWithFallback()` automatically falls back to GENERIC. So you only need to export `[GENERIC]` unless the tool needs model-specific behavior.
3. **Register in `src/core/prompts/system-prompt/tools/init.ts`** - Import and spread into `allToolVariants`
4. **Add to variant configs** - Each model family has its own config in `src/core/prompts/system-prompt/variants/*/config.ts`. Add your tool's enum to the `.tools()` list:
- `generic/config.ts`, `next-gen/config.ts`, `gpt-5/config.ts`, `native-gpt-5/config.ts`, `native-gpt-5-1/config.ts`, `native-next-gen/config.ts`, `gemini-3/config.ts`, `glm/config.ts`, `hermes/config.ts`, `xs/config.ts`
- **Important**: If you add to a variant's config, make sure the tool spec exports a variant for that ModelFamily (or relies on GENERIC fallback)
5. **Create handler** in `src/core/task/tools/handlers/`
6. **Wire up in `ToolExecutor.ts`** if needed for execution flow
7. **Add to tool parsing** in `src/core/assistant-message/index.ts` if needed
8. **If tool has UI feedback**: add `ClineSay` enum in proto, update `src/shared/ExtensionMessage.ts`, update `src/shared/proto-conversions/cline-message.ts`, update `webview-ui/src/components/chat/ChatRow.tsx`
## Modifying System Prompt
**Read these first:** `src/core/prompts/system-prompt/README.md`, `tools/README.md`, `__tests__/README.md`
System prompt is modular: **components** (reusable sections) + **variants** (model-specific configs) + **templates** (with `{{PLACEHOLDER}}` resolution).
**Key directories:**
- `components/` - Shared sections: `rules.ts`, `capabilities.ts`, `editing_files.ts`, etc.
- `variants/` - Model-specific: `generic/`, `next-gen/`, `xs/`, `gpt-5/`, `gemini-3/`, `hermes/`, `glm/`, etc.
- `templates/` - Template engine and placeholder definitions
**Variant tiers (ask user which to modify):**
- **Next-gen** (Claude 4, GPT-5, Gemini 2.5): `next-gen/`, `native-next-gen/`, `native-gpt-5/`, `native-gpt-5-1/`, `gemini-3/`, `gpt-5/`
- **Standard** (default fallback): `generic/`
- **Local/small models**: `xs/`, `hermes/`, `glm/`
**How overrides work:** Variants can override components via `componentOverrides` in their `config.ts`, or provide a custom template in `template.ts` (e.g., `next-gen/template.ts` exports `rules_template`). If no override, the shared component from `components/` is used.
**Example: Adding a rule to RULES section**
1. Check if variant overrides rules: look for `rules_template` in `variants/*/template.ts` or `componentOverrides.RULES` in `config.ts`
2. If shared: modify `components/rules.ts`
3. If overridden: modify that variant's template
4. XS variant is special—has heavily condensed inline content in `template.ts`
**After any changes, regenerate snapshots:**
```bash
UPDATE_SNAPSHOTS=true npm run test:unit
```
Snapshots live in `__tests__/__snapshots__/`. Tests validate across model families and context variations (browser, MCP, focus chain).
## Modifying Default Slash Commands
Three places need updates:
- `src/core/slash-commands/index.ts` - Command definitions
- `src/core/prompts/commands.ts` - System prompt integration
- `webview-ui/src/utils/slash-commands.ts` - Webview autocomplete
## Adding New Global State Keys
Adding a new key to global state requires updates in multiple places. Missing any step causes silent failures.
@@ -151,7 +109,7 @@ Settings plumbing gotcha: if a key is user-toggleable from settings, wire both c
Missing one path causes a toggle to appear to change in one surface while the backend state stays unchanged.
Webview toggle gotcha: settings changes must also round-trip back in state payloads.
- Add the field to `UpdateSettingsRequest` in `proto/cline/state.proto` (for webview update requests), then run `npm run protos`
- Add the field to `UpdateSettingsRequest` in `proto/cline/state.proto` (for webview update requests), then run `bun run protos`
- Include the key in `Controller.getStateToPostToWebview()` (`src/core/controller/index.ts`)
- Ensure `ExtensionState` and webview defaults include the key (`src/shared/ExtensionMessage.ts`, `webview-ui/src/context/ExtensionStateContext.tsx`)
If this round-trip wiring is missing, the backend value can update but the toggle in webview appears stuck or reverts.
@@ -199,3 +157,48 @@ const isGenerating = explanationInfo.status === "generating" && !wasCancelled
**See also:** `BrowserSessionRow.tsx` uses similar pattern with `isLastApiReqInterrupted` and `isLastMessageResume`.
**Backend side:** When streaming is cancelled, clean up properly (close tabs, clear comments, etc.) by checking `taskState.abort` after the streaming function returns.
## Debug Harness: clear inherited VSCode/Electron env vars before launching
The debug harness (`apps/vscode/src/dev/debug-harness/server.ts`) launches a child
VSCode via Playwright's `_electron.launch({ env: { ...process.env, ... } })`. If you
run the harness from a process that was itself spawned by VSCode (e.g. the Cline
extension host, an integrated terminal, or an agent running inside VSCode), the
parent's VSCode/Electron env vars leak into the child and break the launch.
The fatal one is **`ELECTRON_RUN_AS_NODE=1`**: it makes the child VSCode binary run
as plain Node, so it rejects every VSCode CLI flag. Symptom:
```
.../Visual Studio Code.app/Contents/MacOS/Code: bad option: --extensionDevelopmentPath=...
Error: Process failed to launch! (Playwright _electron.launch)
```
This is NOT the macOS Playwright flakiness mentioned in the harness README — it's
env inheritance. Fix: strip the inherited vars before starting the harness:
```bash
env -u ELECTRON_RUN_AS_NODE -u ELECTRON_NO_ATTACH_CONSOLE \
-u VSCODE_CLI -u VSCODE_CODE_CACHE_PATH -u VSCODE_CRASH_REPORTER_PROCESS_TYPE \
-u VSCODE_CWD -u VSCODE_ESM_ENTRYPOINT -u VSCODE_HANDLES_UNCAUGHT_ERRORS \
-u VSCODE_IPC_HOOK -u VSCODE_NLS_CONFIG -u VSCODE_PID -u VSCODE_L10N_BUNDLE_LOCATION \
bun src/dev/debug-harness/server.ts --auto-launch --skip-build
```
Check your own env with `env | grep -iE 'electron|vscode_'` first; `ELECTRON_RUN_AS_NODE=1`
present means you must scrub before launching.
Other harness notes confirmed in practice:
- The extension host is **ESM** (`VSCODE_ESM_ENTRYPOINT`), so `ext.evaluate` has no
`require` and module-internal functions aren't reachable as globals. To inspect
internal builders (e.g. `buildBedrockProviderConfig`), set a breakpoint with
`ext.set_breakpoint` and read locals via `ext.evaluate` with the paused `callFrameId`
— don't try to `require()` the bundle.
- `web.evaluate` wraps the expression as a single returned expression; multi-statement
snippets must be an IIFE `(() => { ...; return x; })()`, otherwise you get
`SyntaxError: Unexpected token ';'`.
- Webview settings inputs are `vscode-text-field` web components with debounced React
onChange. Setting `.value` + dispatching events via `web.evaluate` is unreliable for
some fields; focus the inner shadow `input` then use real keystrokes (`ui.type` +
`ui.press Tab`, or click the dropdown option) to make the value persist.
+1 -1
View File
@@ -42,7 +42,7 @@ Here, we use the common `StringRequest` and `KeyValuePair` types.
After editing a `.proto` file, regenerate the TypeScript code. From the project root, run:
```bash
npm run protos
bun run protos
```
This command compiles all `.proto` files and outputs the generated code to `src/generated/` and `src/shared/`. Do not edit these generated files manually.
+26
View File
@@ -0,0 +1,26 @@
# SDK Adapter
The VSCode extension runs on the Cline SDK (`@cline/core`, `@cline/llms`,
`@cline/shared`) through an adapter layer in `apps/vscode/src/sdk/`. The
webview still talks gRPC; the adapter translates between gRPC handlers and SDK
calls. See `apps/vscode/src/dev/debug-harness/README.md` for the debug harness.
## Conventions
1. **Look up SDK APIs, don't guess.** Use `kb_search(name="sdk", query="...")`
before implementing against an SDK surface.
2. **Reference the pre-SDK implementation when replacing a module.** Add a
`// Replaces classic src/core/... (see origin/main)` header and use
`kb_search(name="cline", commit="origin/main")` or
`git show origin/main:path` to consult the prior implementation.
3. **Single entry point.** There is one codepath — the SDK adapter. No
`CLINE_SDK` env flag.
4. **Use `{appBaseUrl}`**, never hardcode `app.cline.bot`.
5. **Avoid `as` casts.** Use explicit conversion functions with tests. The
branded types in `apps/vscode/src/sdk/model-catalog/contracts.ts` exist so
casts are unnecessary outside parse/compute boundaries.
## Debug harness
- **Dismiss the Kanban/promo overlay** before any debug harness interaction.
- **Use the command palette** to navigate tabs in the debug harness.
+1 -1
View File
@@ -91,7 +91,7 @@ On the main branch, create a commit that updates:
3. No changelog-entry file cleanup is needed. Contributors do not create changelog-entry files in this repo.
**Skip running `npm run install:all`** - release automation handles lockfile consistency as needed.
**No dependency install is needed.** A CHANGELOG + `version` bump does not change any dependency, and `bun.lock` does not pin workspace-package versions, so the lockfile stays consistent. The publish workflow runs `bun install --frozen-lockfile`, which would *fail* on an out-of-sync lock — so only run `bun install` here if you actually change dependencies (then commit the updated `bun.lock`).
Commit with message format: `v{VERSION} Release Notes (hotfix)`
+6 -6
View File
@@ -9,14 +9,14 @@ This is a VS Code extension. Read `.clinerules/general.md` for tribal knowledge
- **MCP**: `src/services/mcp/McpHub.ts`.
## Build & Test (Critical — non-obvious commands)
- **Build**: `npm run compile` — NOT `npm run build`.
- **Watch**: `npm run watch` (extension + webview).
- **Protos**: `npm run protos` — run **immediately** after any `.proto` change. Generates into `src/shared/proto/`, `src/generated/`.
- **Tests**: `npm run test:unit`. After prompt/tool changes: `UPDATE_SNAPSHOTS=true npm run test:unit`.
- **Build**: `bun run compile` — NOT `bun run build`.
- **Watch**: `bun run watch` (extension + webview).
- **Protos**: `bun run protos` — run **immediately** after any `.proto` change. Generates into `src/shared/proto/`, `src/generated/`.
- **Tests**: `bun run test:unit`. After prompt/tool changes: `UPDATE_SNAPSHOTS=true bun run test:unit`.
## Protobuf RPC Workflow (4 steps)
1. **Define** in `proto/cline/*.proto`. Naming: `PascalCaseService`, `camelCase` RPCs, `PascalCase` Messages. Use `common.proto` shared types for simple data.
2. **Generate**: `npm run protos`.
2. **Generate**: `bun run protos`.
3. **Backend handler**: `src/core/controller/<domain>/`.
4. **Frontend call**: `UiServiceClient.myMethod(Request.create({...}))`.
- Adding enums (e.g. `ClineSay`) → also update `src/shared/proto-conversions/cline-message.ts`.
@@ -38,7 +38,7 @@ For Responses API providers: add to `isNextGenModelProvider()` in `src/utils/mod
4. Whitelist in `src/core/prompts/system-prompt/variants/*/config.ts` for each model family.
5. Handler in `src/core/task/tools/handlers/`, wire in `ToolExecutor.ts`.
6. If tool has UI: add `ClineSay` enum in proto → `ExtensionMessage.ts``cline-message.ts``ChatRow.tsx`.
7. Regenerate snapshots: `UPDATE_SNAPSHOTS=true npm run test:unit`.
7. Regenerate snapshots: `UPDATE_SNAPSHOTS=true bun run test:unit`.
## Modifying System Prompt
Modular: `components/` (shared) + `variants/` (model-specific) + `templates/` (`{{PLACEHOLDER}}`). Variants override components via `componentOverrides` in `config.ts` or custom `template.ts`. XS variant is heavily condensed inline. Always regenerate snapshots after changes.
+1 -1
View File
@@ -59,7 +59,7 @@ We're not looking for exhaustive documentation - just evidence that you've thoug
<!-- Put an 'x' in all boxes that apply -->
- [ ] Changes are limited to a single feature, bugfix or chore (split larger changes into separate PRs)
- [ ] Tests are passing (`npm test`) and code is formatted and linted (`npm run format && npm run lint`)
- [ ] Tests are passing (`bun test`) and code is formatted and linted (`bun run format && bun run lint`)
- [ ] I have reviewed [contributor guidelines](https://github.com/cline/cline/blob/main/CONTRIBUTING.md)
### Screenshots
@@ -31,6 +31,9 @@ jobs:
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
runs-on: ubuntu-latest
environment: PublishNightly
# The VS Code extension's package.json and lockfiles live under apps/vscode/
# (the repo root has no package.json). Mirror ext-vscode-test.yml so install
# and publish steps run in the correct workspace.
defaults:
run:
working-directory: apps/vscode
@@ -50,21 +53,47 @@ jobs:
echo "Building sha: $GITHUB_SHA"
git --no-pager log -1 --oneline
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
# Node is still REQUIRED in the publish job (not just for install): the publish
# scripts run as `node ./scripts/publish-*.mjs` and shell out to `npx ovsx`.
# setup-bun does not provide a Node runtime, so keep setup-node here.
# Pinned to Node 22 because newer LTS (Node 24 / npm 11) can make vsce's
# `npm list` dependency detection fail with ELSPROBLEMS during packaging.
- name: Setup Node.js
uses: actions/setup-node@v4
with:
# Keep publish environment aligned with test workflow/tooling lockfile expectations.
# Newer LTS (Node 24 / npm 11) can make `npm list` fail with ELSPROBLEMS during vsce packaging.
node-version: 22
- name: Install extension dependencies
# Single root install resolves the whole bun workspace at once (replaces the
# per-package `npm ci` steps for apps/vscode + webview-ui).
- name: Install workspace dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode ci --include=optional
run: bun install --frozen-lockfile
- name: Install webview-ui dependencies
# @cline/* are local workspace symlinks to source packages; build dist/ before
# packaging/publishing the extension.
- name: Build SDK packages
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui ci --include=optional
run: bun run build:sdk
- name: Assert better-sqlite3 native binary present
run: |
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
if [ ! -f "$NODE_FILE" ]; then
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
echo "(bun trustedDependencies postinstall likely did not run)"
exit 1
fi
echo "Found better-sqlite3 native binary: $NODE_FILE"
# vsce is now a workspace devDependency (on node_modules/.bin), but ovsx is not
# vendored and the publish script invokes it via `npx ovsx`, so install ovsx
# globally here (npm is available via setup-node). vsce is installed globally
# too to preserve the script's existing PATH expectations.
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
@@ -82,7 +111,9 @@ jobs:
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
run: npm run publish:marketplace:nightly
# The script itself runs under `node ./scripts/publish-nightly.mjs`; bun run
# just launches it. Node + npm (for `npx ovsx`) are provided by setup-node above.
run: bun run publish:marketplace:nightly
- name: Tag published commit
working-directory: ${{ github.workspace }}
@@ -109,19 +109,48 @@ jobs:
echo "tag=$TAG" >> $GITHUB_OUTPUT
echo "resolved_sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
# Node is still REQUIRED in the publish job (not just for install): the
# publish scripts run as `node scripts/publish-*.mjs`, the version step uses
# `node -p`, and `npx ovsx` needs npm. setup-bun does not provide a Node
# runtime, so keep setup-node. Pinned to Node 22 because newer LTS
# (Node 24 / npm 11) can make vsce's `npm list` detection fail with
# ELSPROBLEMS during packaging.
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install extension dependencies
# Single root install resolves the whole bun workspace at once (replaces the
# per-package `npm install` steps for apps/vscode + webview-ui).
- name: Install workspace dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode install --include=optional
run: bun install --frozen-lockfile
- name: Install webview-ui dependencies
# @cline/* are local workspace symlinks to source packages; build dist/ before
# packaging/publishing the extension.
- name: Build SDK packages
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui install --include=optional
run: bun run build:sdk
- name: Assert better-sqlite3 native binary present
run: |
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
if [ ! -f "$NODE_FILE" ]; then
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
echo "(bun trustedDependencies postinstall likely did not run)"
exit 1
fi
echo "Found better-sqlite3 native binary: $NODE_FILE"
# vsce is a workspace devDependency (on node_modules/.bin), but ovsx is not
# vendored and the publish script invokes it via `npx ovsx`, so install ovsx
# globally (npm is available via setup-node). vsce is installed globally too
# to preserve the script's existing PATH expectations.
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
@@ -164,14 +193,20 @@ jobs:
node scripts/marketplace-readme.mjs swap-in
trap 'node scripts/marketplace-readme.mjs restore' EXIT
# Required to generate the .vsix
vsce package --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
# Required to generate the .vsix. --no-dependencies: the extension
# is fully esbuild-bundled, and under the bun workspace the @cline/*
# deps are symlinks pointing outside the package, so without this vsce
# would walk them and pull the whole monorepo into the .vsix.
vsce package --no-dependencies --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
# These scripts run under `node scripts/publish-marketplace.mjs`;
# bun run just launches them. Node + npm (for `npx ovsx`) come from
# setup-node above.
if [ "$RELEASE_TYPE" = "pre-release" ]; then
npm run publish:marketplace:prerelease
bun run publish:marketplace:prerelease
echo "Successfully published pre-release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
else
npm run publish:marketplace
bun run publish:marketplace
echo "Successfully published release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
fi
+44 -27
View File
@@ -45,12 +45,16 @@ jobs:
- 'apps/vscode/assets/**'
- 'apps/vscode/walkthrough/**'
- 'apps/vscode/package.json'
- 'apps/vscode/package-lock.json'
- 'apps/vscode/webview-ui/package.json'
# Root bun lockfile is authoritative for the whole workspace (incl. apps/vscode).
- 'bun.lock'
# SDK source packages are local workspace symlinks (@cline/*), so SDK changes affect the build.
- 'sdk/packages/**'
- 'apps/vscode/buf.yaml'
- 'apps/vscode/tsconfig*.json'
- 'apps/vscode/biome.jsonc'
- 'apps/vscode/esbuild.mjs'
- 'apps/vscode/.mocharc.json'
- 'apps/vscode/bunfig.toml'
- 'apps/vscode/.vscode-test.mjs'
- 'apps/vscode/.vscodeignore'
- 'apps/vscode/playwright*.ts'
@@ -84,26 +88,20 @@ jobs:
working-directory: apps/vscode
steps:
- uses: actions/checkout@v4
- name: Setup Node.js environment
uses: actions/setup-node@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
node-version: 22
bun-version: 1.3.14
# Cache root dependencies - only reuse if package-lock.json exactly matches
- name: Cache root dependencies
# Cache Bun's global install cache - keyed on the authoritative root bun.lock.
- name: Cache Bun install cache
uses: actions/cache@v4
id: root-cache
id: bun-cache
with:
path: apps/vscode/node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('apps/vscode/package-lock.json') }}
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
- name: Cache webview-ui dependencies
uses: actions/cache@v4
id: webview-cache
with:
path: apps/vscode/webview-ui/node_modules
key: ${{ runner.os }}-npm-webview-${{ hashFiles('apps/vscode/webview-ui/package-lock.json') }}
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
# Cache VS Code installation
- name: Cache VS Code
@@ -124,22 +122,41 @@ jobs:
~/.cache/ms-playwright
~/Library/Caches/ms-playwright
~/AppData/Local/ms-playwright
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('apps/vscode/package-lock.json') }}
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('bun.lock') }}
restore-keys: |
playwright-browsers-${{ runner.os }}-
- name: Install extension dependencies
# Single root install resolves the entire bun workspace at once (replaces
# the per-package `npm ci` steps for apps/vscode + webview-ui).
- name: Install workspace dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode ci
run: bun install --frozen-lockfile
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install webview-ui dependencies
# @cline/* are local workspace symlinks to source packages; build dist/
# before building/packaging the extension for E2E.
- name: Build SDK packages
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui ci
run: bun run build:sdk
- name: Install vsce
run: npm install -g @vscode/vsce
- name: Assert better-sqlite3 native binary present
# Force bash: the Windows runner defaults to pwsh, which can't parse this
# POSIX test. Git Bash ships on GitHub's windows-latest images.
shell: bash
run: |
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
if [ ! -f "$NODE_FILE" ]; then
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
echo "(bun trustedDependencies postinstall likely did not run)"
exit 1
fi
echo "Found better-sqlite3 native binary: $NODE_FILE"
# NOTE: vsce is no longer installed globally. @vscode/vsce is a workspace
# devDependency of apps/vscode (resolved into node_modules/.bin), and the
# `test:e2e:build` script invokes `vsce` via `bun run`, which puts the local
# .bin on PATH. No global install needed.
- name: Install xvfb on Linux
if: matrix.runner == 'ubuntu'
@@ -148,11 +165,11 @@ jobs:
# Run optimized E2E tests (eliminates redundant builds)
- name: Run E2E tests - Linux
if: matrix.runner == 'ubuntu'
run: xvfb-run -a npm run test:e2e:optimal
run: xvfb-run -a bun run test:e2e:optimal
- name: Run E2E tests - Non-Linux
if: matrix.runner != 'ubuntu'
run: npm run test:e2e:optimal
run: bun run test:e2e:optimal
- uses: actions/upload-artifact@v4
if: ${{ failure() }}
+122 -59
View File
@@ -45,13 +45,16 @@ jobs:
- 'apps/vscode/assets/**'
- 'apps/vscode/walkthrough/**'
- 'apps/vscode/package.json'
- 'apps/vscode/package-lock.json'
- 'apps/vscode/webview-ui/package.json'
# Root bun lockfile is authoritative for the whole workspace (incl. apps/vscode).
- 'bun.lock'
# SDK source packages are local workspace symlinks (@cline/*), so SDK changes affect the build.
- 'sdk/packages/**'
- 'apps/vscode/buf.yaml'
- 'apps/vscode/tsconfig*.json'
- 'apps/vscode/biome.jsonc'
- 'apps/vscode/esbuild.mjs'
- 'apps/vscode/.mocharc.json'
- 'apps/vscode/.nycrc*.json'
- 'apps/vscode/bunfig.toml'
- 'apps/vscode/.vscode-test.mjs'
- 'apps/vscode/test-setup.js'
- '.github/workflows/ext-vscode-test.yml'
@@ -60,9 +63,13 @@ jobs:
- 'apps/vscode/proto/**'
- 'apps/vscode/standalone/**'
- 'apps/vscode/testing-platform/**'
- 'apps/vscode/testing-platform/package.json'
- 'apps/vscode/tests/specs/**'
- 'apps/vscode/package.json'
- 'apps/vscode/package-lock.json'
# Root bun lockfile is authoritative for the whole workspace (incl. apps/vscode).
- 'bun.lock'
# SDK source packages are local workspace symlinks (@cline/*), so SDK changes affect the build.
- 'sdk/packages/**'
- 'apps/vscode/buf.yaml'
- 'apps/vscode/tsconfig*.json'
- 'apps/vscode/esbuild.mjs'
@@ -82,27 +89,38 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js environment
uses: actions/setup-node@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
node-version: 22
cache: 'npm'
cache-dependency-path: |
apps/vscode/package-lock.json
apps/vscode/webview-ui/package-lock.json
bun-version: 1.3.14
- name: Install extension dependencies
# Single root install resolves the entire bun workspace (apps/vscode,
# webview-ui, testing-platform and the @cline/* SDK symlinks) at once,
# so the previous per-package `npm ci` steps collapse into one.
- name: Install workspace dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode ci
run: bun install --frozen-lockfile
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install webview-ui dependencies
# @cline/* are local workspace symlinks to source packages; their dist/
# output must be built before the extension can type-check/compile.
- name: Build SDK packages
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui ci
run: bun run build:sdk
- name: Assert better-sqlite3 native binary present
run: |
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
if [ ! -f "$NODE_FILE" ]; then
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
echo "(bun trustedDependencies postinstall likely did not run)"
exit 1
fi
echo "Found better-sqlite3 native binary: $NODE_FILE"
- name: Run Quality Checks (Parallel)
run: npm run ci:check-all
run: bun run ci:check-all
vscode-test:
needs: [detect-changes, quality-checks]
@@ -123,30 +141,43 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js environment
uses: actions/setup-node@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
node-version: 22
cache: 'npm'
cache-dependency-path: |
apps/vscode/package-lock.json
apps/vscode/webview-ui/package-lock.json
bun-version: 1.3.14
- name: Install extension dependencies
# Single root install resolves the entire bun workspace at once (replaces
# the per-package `npm ci` steps for apps/vscode + webview-ui).
- name: Install workspace dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode ci
run: bun install --frozen-lockfile
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install webview-ui dependencies
# @cline/* are local workspace symlinks to source packages; build dist/
# before compiling/testing the extension.
- name: Build SDK packages
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui ci
run: bun run build:sdk
- name: Set up NPM on Windows
if: runner.os == 'Windows'
working-directory: ${{ github.workspace }}
- name: Assert better-sqlite3 native binary present
run: |
npm config set script-shell "C:\\Program Files\\Git\\bin\\bash.exe"
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
if [ ! -f "$NODE_FILE" ]; then
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
echo "(bun trustedDependencies postinstall likely did not run)"
exit 1
fi
echo "Found better-sqlite3 native binary: $NODE_FILE"
# NOTE: The old `npm config set script-shell bash` step is intentionally
# removed. Scripts are now launched with `bun run`, which uses Bun's own
# built-in cross-platform shell rather than npm's configured script-shell,
# so that npm-specific Windows workaround no longer applies. Bash-dependent
# scripts (e.g. scripts/proto-lint.sh, standalone/runclinecore.sh) are
# invoked explicitly via `bash ...` from within the package scripts, and
# this job's `defaults.run.shell: bash` (Git Bash on Windows) still covers
# the workflow `run:` blocks below.
- name: Cache VS Code test runtime
if: runner.os == 'Windows'
@@ -158,24 +189,51 @@ jobs:
# Build the extension and tests (without redundant checks)
- name: Build Tests and Extension
id: build_step
run: npm run ci:build
run: bun run ci:build
- name: Unit Tests with coverage - Linux
- name: Vitest Suites (SDK adapter + model catalog)
id: vitest_tests
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
# The vitest config sets passWithNoTests: true, so a broken glob/alias
# would "pass" with zero tests. Capture output and assert a non-zero
# test count to guard against silent skips.
run: |
set -o pipefail
bun run test:vitest 2>&1 | tee vitest-output.log
# Strip ANSI color codes before matching — vitest colorizes the
# "Tests N passed" summary, so the count is not adjacent to the
# "Tests" label in the raw bytes.
if ! sed -r 's/\x1b\[[0-9;]*m//g' vitest-output.log | grep -Eq 'Tests[[:space:]]+[0-9]*[1-9][0-9]* (passed|failed)'; then
echo "ERROR: vitest reported zero tests (possible silent skip)."
exit 1
fi
- name: Unit Tests (bun) - Linux
id: unit_tests_linux
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os == 'Linux' }}
# The node-side unit suite (.mocharc spec set) now runs under `bun test`
# via scripts/run-bun-unit-tests.ts (one isolated bun process per file).
# The runner exits non-zero on any failure and prints a final
# "Files: N Pass: P Fail: F" summary; assert a non-zero pass count to
# guard against an empty glob silently "passing".
run: |
npx nyc --nycrc-path .nycrc.unit.json --reporter=lcov npm run test:unit
set -o pipefail
bun run test:unit 2>&1 | tee unit-output.log
if ! grep -Eq 'Pass:[[:space:]]+[0-9]*[1-9][0-9]*' unit-output.log; then
echo "ERROR: bun unit runner reported zero passing tests (possible silent skip)."
exit 1
fi
- name: Unit Tests - Non-Linux
- name: Unit Tests (bun) - Non-Linux
id: unit_tests_non_linux
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os != 'Linux' }}
run: |
npm run test:unit
bun run test:unit
- name: Extension Integration Tests - Linux
id: integration_tests_linux
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os == 'Linux' }}
run: xvfb-run -a npm run test:coverage
run: xvfb-run -a bun run test:coverage
- name: Extension Integration Tests - Non-Linux
id: integration_tests_non_linux
@@ -183,7 +241,7 @@ jobs:
run: |
for attempt in 1 2 3; do
echo "Running extension integration tests (attempt ${attempt}/3)"
if npm run test:integration; then
if bun run test:integration; then
exit 0
fi
@@ -201,7 +259,7 @@ jobs:
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
run: |
cd webview-ui
npm run test:coverage
bun run test:coverage
- name: Save Coverage Reports
uses: actions/upload-artifact@v4
@@ -210,7 +268,6 @@ jobs:
with:
name: pr-coverage-reports
path: |
apps/vscode/coverage-unit/lcov.info
apps/vscode/webview-ui/coverage/lcov.info
test-platform-integration:
@@ -224,39 +281,45 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js environment
uses: actions/setup-node@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
node-version: 22
cache: 'npm'
cache-dependency-path: |
apps/vscode/package-lock.json
apps/vscode/webview-ui/package-lock.json
apps/vscode/testing-platform/package-lock.json
bun-version: 1.3.14
- name: Install extension dependencies
# Single root install resolves the whole bun workspace, including the
# testing-platform package, so the separate per-package `npm ci` steps
# (extension + webview-ui + testing-platform) collapse into one.
- name: Install workspace dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode ci
run: bun install --frozen-lockfile
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install webview-ui dependencies
# @cline/* are local workspace symlinks to source packages; build dist/
# before compiling the standalone core.
- name: Build SDK packages
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui ci
run: bun run build:sdk
- name: Assert better-sqlite3 native binary present
run: |
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
if [ ! -f "$NODE_FILE" ]; then
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
echo "(bun trustedDependencies postinstall likely did not run)"
exit 1
fi
echo "Found better-sqlite3 native binary: $NODE_FILE"
- name: Download ripgrep binaries
run: npm run download-ripgrep
run: bun run download-ripgrep
- name: Compile Standalone
run: npm run compile-standalone
- name: Install testing platform dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/testing-platform ci
run: bun run compile-standalone
- name: Running testing platform integration spec tests
timeout-minutes: 7
run: npm run test:tp-orchestrator -- tests/specs/ --count=1 --coverage
run: bun run test:tp-orchestrator -- tests/specs/ --count=1 --coverage
- name: Save Coverage Reports
uses: actions/upload-artifact@v4
+4
View File
@@ -13,6 +13,9 @@ tmp
pnpm-lock.yaml
.clineignore
.cline/enterprise
.cline/remote-config
**/.cline/remote-config
.venv
.actrc
CLAUDE.local.md
@@ -81,3 +84,4 @@ apps/vscode/webview-ui/src/**/*.js.map
*.db-wal
.cline/**/managed.json
.cline/**/bundle.json
apps/vscode/tsconfig.test.generated.json
+2 -1
View File
@@ -7,4 +7,5 @@ fi
gitleaks git --pre-commit --redact --staged --verbose || exit 1
lint-staged
cd apps/vscode && bunx lint-staged
+2 -5
View File
@@ -126,10 +126,7 @@
"${workspaceFolder}/apps/vscode/dist-standalone/**/*.js"
],
"preLaunchTask": "compile-standalone",
"runtimeExecutable": "npx",
"runtimeArgs": [
"tsx"
],
"runtimeExecutable": "bun",
"program": "scripts/test-standalone-core-api-server.ts",
"envFile": "${workspaceFolder}/apps/vscode/.env",
"env": {
@@ -183,7 +180,7 @@
"name": "Open Storybook",
"type": "node",
"request": "launch",
"runtimeExecutable": "npm",
"runtimeExecutable": "bun",
"runtimeArgs": [
"run",
"storybook"
+10 -10
View File
@@ -6,7 +6,7 @@
{
"label": "compile-standalone",
"type": "shell",
"command": "npm run compile-standalone",
"command": "bun run compile-standalone",
"group": "build",
"problemMatcher": [],
"presentation": {
@@ -19,7 +19,7 @@
{
"label": "npm: protos",
"type": "shell",
"command": "npm run protos",
"command": "bun run protos",
"problemMatcher": [],
"isBackground": false,
"presentation": {
@@ -65,7 +65,7 @@
},
{
"type": "shell",
"command": "npm run build:webview",
"command": "bun run build:webview",
"group": "build",
"problemMatcher": [],
"isBackground": true,
@@ -86,7 +86,7 @@
},
{
"type": "shell",
"command": "npm run build:webview:test",
"command": "bun run build:webview:test",
"group": "build",
"problemMatcher": [],
"isBackground": true,
@@ -108,7 +108,7 @@
},
{
"type": "shell",
"command": "npm run dev:webview",
"command": "bun run dev:webview",
"group": "build",
"problemMatcher": [
{
@@ -145,7 +145,7 @@
},
{
"type": "shell",
"command": "npm run watch:esbuild",
"command": "bun run watch:esbuild",
"group": "build",
"problemMatcher": {
"pattern": [
@@ -184,7 +184,7 @@
},
{
"type": "shell",
"command": "npm run watch:esbuild:test",
"command": "bun run watch:esbuild:test",
"group": "build",
"problemMatcher": {
"pattern": [
@@ -224,7 +224,7 @@
},
{
"type": "shell",
"command": "npm run watch:tsc",
"command": "bun run watch:tsc",
"group": "build",
"problemMatcher": "$tsc-watch",
"isBackground": true,
@@ -242,7 +242,7 @@
},
{
"type": "shell",
"command": "npm run watch-tests",
"command": "bun run watch-tests",
"label": "npm: watch-tests",
"problemMatcher": "$tsc-watch",
"isBackground": true,
@@ -282,7 +282,7 @@
},
{
"type": "shell",
"command": "npm run storybook",
"command": "bun run storybook",
"group": "build",
"problemMatcher": [],
"isBackground": false,
-32
View File
@@ -1,37 +1,5 @@
# Changelog
## [4.0.4]
### Changed
- Fully remove the ClinePass feature flag so ClinePass is available everywhere in the UI — onboarding, settings, the welcome promo banner, and the credit-limit "Switch to ClinePass" action.
## [4.0.3]
### Changed
- Enable the ClinePass provider for all users by removing the feature-flag gate that previously fell back to the standard Cline provider.
## [4.0.2]
### Added
- Add reasoning effort support (including `xhigh`) for DeepSeek thinking models.
- Improve the ClinePass provider experience with clearer reasoning controls and model selection.
### Fixed
- Show reasoning effort controls for ClinePass models and align ClinePass model resolution with the rest of the provider.
- Prefer canonical Cline Z.ai model ids and polish ClinePass and Z.ai model metadata.
- Fix environment variable replacement in the webview.
- Default focus chain settings in webview state so the toggle reflects the correct value on load.
## [4.0.1]
### Changed
- Roll the stable VS Code extension back to the pre-SDK-migration codebase to resolve regressions reported in 4.0.0. This release ships the 3.89.2 extension code under a higher version number so existing 4.0.0 users receive the update. SDK-migration work continues separately on `main`.
## [3.89.2]
### Fixed
-2
View File
@@ -1,2 +0,0 @@
@.clinerules/general.md
@.clinerules/network.md
+14 -14
View File
@@ -45,7 +45,7 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
3. Install [bun](https://bun.com)
4. Install the necessary dependencies for the extension and webview-gui:
```bash
cd apps/vscode && npm run install:all && cd ../..
cd apps/vscode && bun run install:all && cd ../..
cd sdk && bun run build && cd ..
```
5. Generate Protocol Buffer files (required before first build):
@@ -61,8 +61,8 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
2. Push your branch and create a PR on GitHub. Our CI will:
- Run tests and checks
3. Testing
- Run `cd apps/vscode && npm run test` to run tests locally.
- Before submitting PR, run `npm run format:fix` to format your code
- Run `cd apps/vscode && bun run test` to run tests locally.
- Before submitting PR, run `bun run format:fix` to format your code
### Extension
@@ -74,12 +74,12 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
2. **Local Development**
- cd into the vscode extension, `cd apps/vscode`
- Run `npm run install:all` to install dependencies
- Run `npm run protos` to generate Protocol Buffer files (required before first build)
- Run `npm run test` to run tests locally
- Run `bun run install:all` to install dependencies
- Run `bun run protos` to generate Protocol Buffer files (required before first build)
- Run `bun run test` to run tests locally
- Run → Start Debugging or `>Debug: Select and Start Debugging` and wait for a new VS Code instance to open
- **Terminal Workflow**: Use `npm run dev` (generates protos + runs watch mode) or `npm run watch` (if protos already generated)
- Before submitting PR, run `npm run format:fix` to format your code
- **Terminal Workflow**: Use `bun run dev` (generates protos + runs watch mode) or `bun run watch` (if protos already generated)
- Before submitting PR, run `bun run format:fix` to format your code
3. **Linux-specific Setup**
VS Code extension tests on Linux require the following system libraries:
@@ -135,8 +135,8 @@ Anyone can contribute code to Cline, but we ask that you follow these guidelines
2. **Code Quality**
- Run `npm run lint` to check code style
- Run `npm run format` to automatically format code
- Run `bun run lint` to check code style
- Run `bun run format` to automatically format code
- All PRs must pass CI checks which include both linting and formatting
- Address any warnings or errors from linter before submitting
- Follow TypeScript best practices and maintain type safety
@@ -144,7 +144,7 @@ Anyone can contribute code to Cline, but we ask that you follow these guidelines
3. **Testing**
- Add tests for new features
- Run `npm test` to ensure all tests pass
- Run `bun test` to ensure all tests pass
- Update existing tests if your changes affect them
- Include both unit tests and integration tests where appropriate
@@ -154,9 +154,9 @@ Anyone can contribute code to Cline, but we ask that you follow these guidelines
- **Running E2E tests:**
```bash
npm run test:e2e # Build and run all E2E tests
npm run e2e # Run tests without rebuilding
npm run test:e2e -- --debug # Run with interactive debugger
bun run test:e2e # Build and run all E2E tests
bun run e2e # Run tests without rebuilding
bun run test:e2e -- --debug # Run with interactive debugger
```
- **Writing E2E tests:**
-1
View File
@@ -26,7 +26,6 @@ test.describe("root flag descriptions", () => {
"Set reasoning effort level",
"consecutive mistakes",
"Output messages as JSON",
"ACP",
"Check for updates and install if available",
"Run the kanban app",
]);
@@ -57,7 +57,7 @@ describe("formatStatusBarUsageText", () => {
totalCost: 0.123,
showCost: true,
}),
).toBe("(12,345 tokens) $0.12");
).toBe("(12,345) $0.12");
});
it("omits cost when usage cost is hidden", () => {
@@ -67,6 +67,6 @@ describe("formatStatusBarUsageText", () => {
totalCost: 0.123,
showCost: false,
}),
).toBe("(12,345 tokens)");
).toBe("(12,345)");
});
});
+1 -1
View File
@@ -51,7 +51,7 @@ export function formatStatusBarUsageText(input: {
totalCost: number;
showCost: boolean;
}): string {
const tokens = `(${input.totalTokens.toLocaleString()} tokens)`;
const tokens = `(${input.totalTokens.toLocaleString()})`;
if (!input.showCost) return tokens;
return `${tokens} ${formatCost(input.totalCost)}`;
}
+11
View File
@@ -57,6 +57,17 @@ describe("MCP wizard settings", () => {
expect(Object.keys(parsed.mcpServers ?? {})).toEqual(["added"]);
});
it("creates the settings file when adding a server to a missing path", async () => {
const settingsPath = await useTempSettingsPath();
addServer("added", { type: "stdio", command: "npx", args: ["server"] });
const parsed = JSON.parse(await readFile(settingsPath, "utf8")) as {
mcpServers?: Record<string, unknown>;
};
expect(Object.keys(parsed.mcpServers ?? {})).toEqual(["added"]);
});
it("parses quoted stdio command arguments", () => {
expect(
parseStdioCommand('npx -y "@scope/server name" --root "my dir"'),
+68 -66
View File
@@ -1,8 +1,9 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { existsSync, readFileSync } from "node:fs";
import {
type McpServerOAuthState,
McpSettingsUpdateSkippedError,
resolveDefaultMcpSettingsPath,
updateMcpSettingsFileSync,
} from "@cline/core";
export interface McpServerEntry {
@@ -56,28 +57,6 @@ export function loadServers(): McpServerEntry[] {
}
}
function readRawSettings(): Record<string, unknown> {
const path = getSettingsPath();
if (!existsSync(path)) return {};
try {
const raw = readFileSync(path, "utf-8");
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
}
function readRawServers(): Record<string, unknown> {
const settings = readRawSettings();
const servers = settings.mcpServers;
return servers && typeof servers === "object" && !Array.isArray(servers)
? { ...(servers as Record<string, unknown>) }
: {};
}
function getOwnServerRecord(
servers: Record<string, unknown>,
name: string,
@@ -92,62 +71,85 @@ function getOwnServerRecord(
return value as Record<string, unknown>;
}
function writeServers(servers: Record<string, unknown>): void {
const path = getSettingsPath();
const settings = readRawSettings();
mkdirSync(dirname(path), { recursive: true });
writeFileSync(
path,
`${JSON.stringify({ ...settings, mcpServers: servers }, null, 2)}\n`,
);
/**
* Mutate the MCP settings file through @cline/core's locked read-update-write
* helper. The mutator must be synchronous and pure; the helper may call it more
* than once to verify deterministic output. Throw McpSettingsUpdateSkippedError
* for normal no-op cases instead of returning a boolean that callers can ignore.
*/
function mutateServers(mutate: (servers: Record<string, unknown>) => void): void {
updateMcpSettingsFileSync(getSettingsPath(), (settings) => {
const serversValue = settings.mcpServers;
const servers = serversValue && typeof serversValue === "object" && !Array.isArray(serversValue)
? { ...(serversValue as Record<string, unknown>) }
: {};
mutate(servers);
settings.mcpServers = servers;
});
}
export function addServer(name: string, transport: McpTransport): void {
const servers = readRawServers();
servers[name] = { transport };
writeServers(servers);
mutateServers((servers) => {
servers[name] = { transport };
});
}
export function removeServer(name: string): boolean {
const servers = readRawServers();
if (!(name in servers)) return false;
delete servers[name];
writeServers(servers);
return true;
try {
mutateServers((servers) => {
if (!(name in servers)) {
throw new McpSettingsUpdateSkippedError(`MCP server not found: ${name}`);
}
delete servers[name];
});
return true;
} catch (error) {
if (error instanceof McpSettingsUpdateSkippedError) {
return false;
}
throw error;
}
}
export function updateServer(name: string, transport: McpTransport): void {
const servers = readRawServers();
const existing =
servers[name] && typeof servers[name] === "object"
? (servers[name] as Record<string, unknown>)
: {};
servers[name] = { ...existing, transport };
writeServers(servers);
mutateServers((servers) => {
const existing =
servers[name] && typeof servers[name] === "object"
? (servers[name] as Record<string, unknown>)
: {};
servers[name] = { ...existing, transport };
});
}
export function clearServerOAuth(name: string): void {
const servers = readRawServers();
const existing = getOwnServerRecord(servers, name);
if (!existing) {
return;
try {
mutateServers((servers) => {
const existing = getOwnServerRecord(servers, name);
if (!existing) {
throw new McpSettingsUpdateSkippedError(`MCP server not found: ${name}`);
}
delete existing.oauth;
servers[name] = existing;
});
} catch (error) {
if (error instanceof McpSettingsUpdateSkippedError) {
return;
}
throw error;
}
delete existing.oauth;
servers[name] = existing;
writeServers(servers);
}
export function toggleServer(name: string, disabled: boolean): void {
const servers = readRawServers();
const existing =
servers[name] && typeof servers[name] === "object"
? (servers[name] as Record<string, unknown>)
: {};
if (disabled) {
existing.disabled = true;
} else {
delete existing.disabled;
}
servers[name] = existing;
writeServers(servers);
mutateServers((servers) => {
const existing =
servers[name] && typeof servers[name] === "object"
? (servers[name] as Record<string, unknown>)
: {};
if (disabled) {
existing.disabled = true;
} else {
delete existing.disabled;
}
servers[name] = existing;
});
}
+2 -21
View File
@@ -1,5 +1,3 @@
import { isIP } from "node:net";
export interface ClineHubServerOptions {
host: string;
port: number;
@@ -49,9 +47,6 @@ function normalizePublicUrl(
`PUBLIC_URL must use http: or https:, got ${parsed.protocol}`,
);
}
if (shouldAddDashboardPortToPublicUrl(parsed, port)) {
parsed.port = String(port);
}
parsed.hash = "";
return parsed.toString().replace(/\/$/, "");
}
@@ -90,26 +85,12 @@ export function resolveClineHubServerOptions(
};
}
function isDefaultProtocolPort(url: URL, port: number): boolean {
return (
(url.protocol === "http:" && port === 80) ||
(url.protocol === "https:" && port === 443)
);
}
function shouldAddDashboardPortToPublicUrl(url: URL, port: number): boolean {
if (url.port || isDefaultProtocolPort(url, port)) return false;
const hostname = url.hostname.replace(/^\[|\]$/g, "");
return hostname === "localhost" || isIP(hostname) !== 0;
}
export function buildInviteUrl(
publicUrl: string,
roomSecret: string | undefined,
): string {
if (!roomSecret) return publicUrl;
const url = new URL(publicUrl);
if (roomSecret) {
url.searchParams.set("roomSecret", roomSecret);
}
url.searchParams.set("roomSecret", roomSecret);
return url.toString();
}
+9 -42
View File
@@ -4,7 +4,6 @@ import {
handleToolApprovalResponse,
rejectOrphanedApprovals,
} from "./server/approvals";
import { isAuthorizedBrowserToDesktopRequest } from "./server/browser-auth";
import {
browserConfig,
host,
@@ -15,11 +14,7 @@ import {
webviewDistDir,
} from "./server/deps";
import { handleDesktopCommand } from "./server/desktop-commands";
import {
createJsonResponse,
isWebviewRoute,
WebviewAssets,
} from "./server/http";
import { createJsonResponse, WebviewAssets } from "./server/http";
import {
attachHub,
detachHub,
@@ -58,33 +53,17 @@ export interface ClineHubDashboardServer {
stop: () => Promise<void>;
}
const PUBLIC_BROWSER_PATHS = new Set([
"/version",
"/health",
"/config.json",
"/api/marketplace/catalog",
"/icon.png",
"/icon.svg",
"/icon.ico",
"/32x32.png",
"/cline-logo-filled.svg",
"/favicon.svg",
]);
function isPublicStaticAssetPath(pathname: string): boolean {
return pathname.startsWith("/assets/") || PUBLIC_BROWSER_PATHS.has(pathname);
}
function isPublicBrowserRoute(_req: Request, url: URL): boolean {
return isWebviewRoute(url.pathname) || isPublicStaticAssetPath(url.pathname);
}
export async function startClineHubDashboardServer(): Promise<ClineHubDashboardServer> {
const ctx = new HubContext();
const assets = new WebviewAssets(webviewDistDir);
const syncClientsAndSessions = () => syncHubClientsAndSessions(ctx);
let stopped = false;
function isAuthorizedBrowserRequest(url: URL): boolean {
if (!roomSecret) return true;
return url.searchParams.get("roomSecret") === roomSecret;
}
await attachHub(ctx);
const healthInterval = setInterval(() => {
void (async () => {
@@ -98,21 +77,6 @@ export async function startClineHubDashboardServer(): Promise<ClineHubDashboardS
hostname: host,
async fetch(req, server) {
const url = new URL(req.url);
if (
!isAuthorizedBrowserToDesktopRequest(
req,
url,
{
bindHost: host,
port,
publicUrl,
roomSecret,
},
isPublicBrowserRoute,
)
) {
return createJsonResponse({ error: "unauthorized_browser" }, 403);
}
if (url.pathname === "/version") {
return createJsonResponse({ coreVersion: CORE_BUILD_VERSION });
}
@@ -121,6 +85,9 @@ export async function startClineHubDashboardServer(): Promise<ClineHubDashboardS
return createJsonResponse(hubStatusPayload(ctx));
}
if (url.pathname === "/browser") {
if (!isAuthorizedBrowserRequest(url)) {
return createJsonResponse({ error: "invalid_room_secret" }, 401);
}
const displayName = `Browser ${Math.random().toString(36).slice(2, 6)}`;
const data = {
socket: undefined as never,
@@ -1,359 +0,0 @@
import { describe, expect, it } from "vitest";
import {
allowedBrowserHosts,
allowedBrowserOrigins,
isAuthorizedBrowserRequest,
isAuthorizedBrowserToDesktopRequest,
requiresBrowserRequestAuth,
} from "./browser-auth";
const defaultOptions = {
bindHost: "127.0.0.1",
port: 8787,
publicUrl: "http://127.0.0.1:8787",
};
const publicRoute = (_req: Request, url: URL) => url.pathname === "/public";
function browserRequest(
origin?: string,
init?: Omit<RequestInit, "headers"> & {
headers?: Record<string, string>;
},
): Request {
return new Request("http://127.0.0.1:8787/browser", {
...init,
headers: {
host: "127.0.0.1:8787",
...(origin === undefined ? {} : { origin }),
...(init?.headers ?? {}),
},
});
}
describe("allowedBrowserOrigins", () => {
it("allows the configured public URL origin and local aliases for local binds", () => {
expect([...allowedBrowserOrigins(defaultOptions)].sort()).toEqual([
"http://127.0.0.1:8787",
"http://[::1]:8787",
"http://localhost:8787",
]);
});
it("uses the configured public URL scheme for local aliases", () => {
expect(
[
...allowedBrowserOrigins({
...defaultOptions,
publicUrl: "https://127.0.0.1:8787",
}),
].sort(),
).toEqual([
"https://127.0.0.1:8787",
"https://[::1]:8787",
"https://localhost:8787",
]);
});
it("omits default protocol ports for local alias origins", () => {
expect(
[
...allowedBrowserOrigins({
bindHost: "127.0.0.1",
port: 80,
publicUrl: "http://localhost",
}),
].sort(),
).toEqual(["http://127.0.0.1", "http://[::1]", "http://localhost"]);
expect(
[
...allowedBrowserOrigins({
bindHost: "127.0.0.1",
port: 443,
publicUrl: "https://localhost",
}),
].sort(),
).toEqual(["https://127.0.0.1", "https://[::1]", "https://localhost"]);
});
it("allows the configured public URL origin and explicit bind origin for non-local binds", () => {
expect(
[
...allowedBrowserOrigins({
bindHost: "0.0.0.0",
port: 8787,
publicUrl: "https://example.ngrok-free.app",
roomSecret: "secret",
}),
].sort(),
).toEqual(["https://0.0.0.0:8787", "https://example.ngrok-free.app"]);
});
});
describe("allowedBrowserHosts", () => {
it("allows the configured public URL host and local aliases for local binds", () => {
expect([...allowedBrowserHosts(defaultOptions)].sort()).toEqual([
"127.0.0.1:8787",
"[::1]:8787",
"localhost:8787",
]);
});
it("omits default protocol ports for local alias hosts", () => {
expect(
[
...allowedBrowserHosts({
bindHost: "127.0.0.1",
port: 80,
publicUrl: "http://localhost",
}),
].sort(),
).toEqual(["127.0.0.1", "[::1]", "localhost"]);
expect(
[
...allowedBrowserHosts({
bindHost: "127.0.0.1",
port: 443,
publicUrl: "https://localhost",
}),
].sort(),
).toEqual(["127.0.0.1", "[::1]", "localhost"]);
});
it("allows the configured public URL host and explicit bind host for non-local binds", () => {
expect(
[
...allowedBrowserHosts({
bindHost: "0.0.0.0",
port: 8787,
publicUrl: "https://example.ngrok-free.app",
roomSecret: "secret",
}),
].sort(),
).toEqual(["0.0.0.0:8787", "example.ngrok-free.app"]);
});
});
describe("requiresBrowserRequestAuth", () => {
it("does not require browser auth for public GET routes", () => {
expect(
requiresBrowserRequestAuth(
new Request("http://127.0.0.1:8787/public"),
new URL("http://127.0.0.1:8787/public"),
publicRoute,
),
).toBe(false);
});
it("requires browser auth for unknown paths even when they use GET", () => {
expect(
requiresBrowserRequestAuth(
new Request("http://127.0.0.1:8787/future-api"),
new URL("http://127.0.0.1:8787/future-api"),
publicRoute,
),
).toBe(true);
});
it("requires browser auth for privileged paths even when they use GET", () => {
expect(
requiresBrowserRequestAuth(
new Request("http://127.0.0.1:8787/browser"),
new URL("http://127.0.0.1:8787/browser"),
publicRoute,
),
).toBe(true);
});
it("requires browser auth for every WebSocket upgrade path", () => {
expect(
requiresBrowserRequestAuth(
new Request("http://127.0.0.1:8787/future-socket", {
headers: { upgrade: "websocket" },
}),
new URL("http://127.0.0.1:8787/future-socket"),
publicRoute,
),
).toBe(true);
});
it("requires browser auth for every unsafe HTTP method", () => {
expect(
requiresBrowserRequestAuth(
new Request("http://127.0.0.1:8787/future-api", { method: "POST" }),
new URL("http://127.0.0.1:8787/future-api"),
publicRoute,
),
).toBe(true);
});
});
describe("isAuthorizedBrowserRequest", () => {
it.each([
"http://127.0.0.1:8787",
"http://localhost:8787",
"http://[::1]:8787",
])("accepts local dashboard origin %s without a room secret", (origin) => {
expect(
isAuthorizedBrowserRequest(
browserRequest(origin),
new URL("http://127.0.0.1:8787/browser"),
defaultOptions,
),
).toBe(true);
});
it.each([
undefined,
"",
"null",
"not a url",
"http://evil.attacker.example.com",
"http://127.0.0.1:9999",
"https://127.0.0.1:8787",
])("rejects untrusted origin %s", (origin) => {
expect(
isAuthorizedBrowserRequest(
browserRequest(origin),
new URL("http://127.0.0.1:8787/browser"),
defaultOptions,
),
).toBe(false);
});
it.each([
undefined,
"",
"evil.attacker.example.com",
"127.0.0.1:9999",
"localhost:9999",
])("rejects untrusted host %s", (host) => {
expect(
isAuthorizedBrowserRequest(
browserRequest("http://127.0.0.1:8787", {
headers: host === undefined ? { host: "" } : { host },
}),
new URL("http://127.0.0.1:8787/browser"),
defaultOptions,
),
).toBe(false);
});
it("allows explicit wildcard bind host and origin when a room secret is configured", () => {
expect(
isAuthorizedBrowserRequest(
browserRequest("http://0.0.0.0:8787", {
headers: { host: "0.0.0.0:8787" },
}),
new URL("http://0.0.0.0:8787/browser?roomSecret=invite-123"),
{
bindHost: "0.0.0.0",
port: 8787,
publicUrl: "http://127.0.0.1:8787",
roomSecret: "invite-123",
},
),
).toBe(true);
});
it("requires trusted origin, trusted host, and room secret when a room secret is configured", () => {
const options = { ...defaultOptions, roomSecret: "invite-123" };
expect(
isAuthorizedBrowserRequest(
browserRequest("http://127.0.0.1:8787"),
new URL("http://127.0.0.1:8787/browser?roomSecret=invite-123"),
options,
),
).toBe(true);
expect(
isAuthorizedBrowserRequest(
browserRequest("http://127.0.0.1:8787"),
new URL("http://127.0.0.1:8787/browser"),
options,
),
).toBe(false);
expect(
isAuthorizedBrowserRequest(
browserRequest("http://evil.attacker.example.com"),
new URL("http://127.0.0.1:8787/browser?roomSecret=invite-123"),
options,
),
).toBe(false);
expect(
isAuthorizedBrowserRequest(
browserRequest("http://127.0.0.1:8787", {
headers: { host: "evil.attacker.example.com" },
}),
new URL("http://127.0.0.1:8787/browser?roomSecret=invite-123"),
options,
),
).toBe(false);
});
});
describe("isAuthorizedBrowserToDesktopRequest", () => {
it("allows safe public GET routes without an origin", () => {
expect(
isAuthorizedBrowserToDesktopRequest(
new Request("http://127.0.0.1:8787/public"),
new URL("http://127.0.0.1:8787/public"),
defaultOptions,
publicRoute,
),
).toBe(true);
});
it("rejects future WebSocket paths from untrusted origins by default", () => {
expect(
isAuthorizedBrowserToDesktopRequest(
new Request("http://127.0.0.1:8787/future-socket", {
headers: {
host: "127.0.0.1:8787",
origin: "http://evil.attacker.example.com",
upgrade: "websocket",
},
}),
new URL("http://127.0.0.1:8787/future-socket"),
defaultOptions,
publicRoute,
),
).toBe(false);
});
it("rejects future unsafe HTTP routes from untrusted origins by default", () => {
expect(
isAuthorizedBrowserToDesktopRequest(
new Request("http://127.0.0.1:8787/future-api", {
method: "POST",
headers: {
host: "127.0.0.1:8787",
origin: "http://evil.attacker.example.com",
},
}),
new URL("http://127.0.0.1:8787/future-api"),
defaultOptions,
publicRoute,
),
).toBe(false);
});
it("allows future unsafe HTTP routes from trusted origins", () => {
expect(
isAuthorizedBrowserToDesktopRequest(
new Request("http://127.0.0.1:8787/future-api", {
method: "POST",
headers: {
host: "127.0.0.1:8787",
origin: "http://127.0.0.1:8787",
},
}),
new URL("http://127.0.0.1:8787/future-api"),
defaultOptions,
publicRoute,
),
).toBe(true);
});
});
-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)
);
}
+33 -27
View File
@@ -1,5 +1,5 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { existsSync, readFileSync } from "node:fs";
import { updateMcpSettingsFileSync } from "@cline/core";
import { resolveMcpSettingsPath } from "@cline/shared/storage";
import type { JsonRecord } from "./types";
@@ -65,9 +65,9 @@ export function readMcpServersResponse(): JsonRecord {
}
export function writeMcpServersMap(servers: JsonRecord): void {
const path = resolveMcpSettingsPath();
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${JSON.stringify({ mcpServers: servers }, null, 2)}\n`);
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
settings.mcpServers = servers;
});
}
export function ensureMcpSettingsFile(): string {
@@ -78,23 +78,21 @@ export function ensureMcpSettingsFile(): string {
return path;
}
function readServersMap(): { path: string; servers: JsonRecord } {
const path = ensureMcpSettingsFile();
const parsed = JSON.parse(readFileSync(path, "utf8")) as JsonRecord;
return { path, servers: (parsed.mcpServers as JsonRecord | undefined) ?? {} };
}
export function setMcpServerDisabled(
name: string,
disabled: boolean,
): JsonRecord {
const { servers } = readServersMap();
const current = servers[name];
if (!current || typeof current !== "object") {
throw new Error(`unknown MCP server: ${name}`);
}
servers[name] = { ...(current as JsonRecord), disabled };
writeMcpServersMap(servers);
// Hold the cross-process lock across read-modify-write so a concurrent writer
// (the extension, the CLI) cannot clobber this change.
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
const current = servers[name];
if (!current || typeof current !== "object") {
throw new Error(`unknown MCP server: ${name}`);
}
servers[name] = { ...(current as JsonRecord), disabled };
settings.mcpServers = servers;
});
return readMcpServersResponse();
}
@@ -127,19 +125,27 @@ export function upsertMcpServer(input: JsonRecord): JsonRecord {
},
disabled: input.disabled === true,
};
const { servers } = readServersMap();
if (previousName && previousName !== name) {
delete servers[previousName];
}
servers[name] = next;
writeMcpServersMap(servers);
// Hold the cross-process lock across read-modify-write so a concurrent writer
// cannot clobber this upsert.
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
if (previousName && previousName !== name) {
delete servers[previousName];
}
servers[name] = next;
settings.mcpServers = servers;
});
return readMcpServersResponse();
}
export function deleteMcpServer(name: string): JsonRecord {
if (!name) throw new Error("server name is required");
const { servers } = readServersMap();
delete servers[name];
writeMcpServersMap(servers);
// Hold the cross-process lock across read-modify-write so a concurrent writer
// cannot resurrect the deleted server from a stale snapshot.
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
delete servers[name];
settings.mcpServers = servers;
});
return readMcpServersResponse();
}
-17
View File
@@ -41,23 +41,6 @@ expectEqual(
"invite URL",
);
const tailscale = resolveClineHubServerOptions({
HOST: "0.0.0.0",
CLINE_HUB_DASHBOARD_PORT: "8787",
PUBLIC_URL: "http://100.82.5.118",
ROOM_SECRET: "invite-123",
});
expectEqual(
tailscale.publicUrl,
"http://100.82.5.118:8787",
"direct IP public URL gets dashboard port",
);
expectEqual(
buildInviteUrl(tailscale.publicUrl, tailscale.roomSecret),
"http://100.82.5.118:8787/?roomSecret=invite-123",
"invite URL for direct IP public URL",
);
expectThrows(
() => resolveClineHubServerOptions({ HOST: "0.0.0.0" }),
"non-local bind without ROOM_SECRET",
+30 -28
View File
@@ -1,11 +1,9 @@
import { execFileSync, spawn } from "node:child_process";
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { basename, dirname, extname, join } from "node:path";
import type {
@@ -45,6 +43,7 @@ import {
setDisabledPlugin,
setDisabledTools,
toggleDisabledTool,
updateMcpSettingsFileSync,
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import { broadcastEvent, resolveSidecarAskQuestion } from "./context";
@@ -143,9 +142,9 @@ function readMcpServersResponse(): JsonRecord {
}
function writeMcpServersMap(servers: JsonRecord): void {
const path = resolveMcpSettingsPath();
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${JSON.stringify({ mcpServers: servers }, null, 2)}\n`);
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
settings.mcpServers = servers;
});
}
function ensureMcpSettingsFile(): string {
@@ -1043,18 +1042,19 @@ export async function handleCommand(
}
if (command === "set_mcp_server_disabled") {
const path = ensureMcpSettingsFile();
const parsed = JSON.parse(readFileSync(path, "utf8")) as JsonRecord;
const servers = (parsed.mcpServers as JsonRecord | undefined) ?? {};
const name = String(args?.name ?? "").trim();
const current = servers[name];
if (!current || typeof current !== "object") {
throw new Error(`unknown MCP server: ${name}`);
}
servers[name] = {
...(current as JsonRecord),
disabled: Boolean(args?.disabled),
};
writeMcpServersMap(servers);
updateMcpSettingsFileSync(path, (settings) => {
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
const name = String(args?.name ?? "").trim();
const current = servers[name];
if (!current || typeof current !== "object") {
throw new Error(`unknown MCP server: ${name}`);
}
servers[name] = {
...(current as JsonRecord),
disabled: Boolean(args?.disabled),
};
settings.mcpServers = servers;
});
return readMcpServersResponse();
}
if (command === "upsert_mcp_server") {
@@ -1093,21 +1093,23 @@ export async function handleCommand(
metadata: input.metadata,
};
const path = ensureMcpSettingsFile();
const parsed = JSON.parse(readFileSync(path, "utf8")) as JsonRecord;
const servers = (parsed.mcpServers as JsonRecord | undefined) ?? {};
if (previousName && previousName !== name) {
delete servers[previousName];
}
servers[name] = next;
writeMcpServersMap(servers);
updateMcpSettingsFileSync(path, (settings) => {
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
if (previousName && previousName !== name) {
delete servers[previousName];
}
servers[name] = next;
settings.mcpServers = servers;
});
return readMcpServersResponse();
}
if (command === "delete_mcp_server") {
const path = ensureMcpSettingsFile();
const parsed = JSON.parse(readFileSync(path, "utf8")) as JsonRecord;
const servers = (parsed.mcpServers as JsonRecord | undefined) ?? {};
delete servers[String(args?.name ?? "")];
writeMcpServersMap(servers);
updateMcpSettingsFileSync(path, (settings) => {
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
delete servers[String(args?.name ?? "")];
settings.mcpServers = servers;
});
return readMcpServersResponse();
}
if (command === "ensure_mcp_settings_file") {
-16
View File
@@ -1,16 +0,0 @@
{
"extension": [
"ts"
],
"spec": [
"src/**/__tests__/*.ts",
"src/test/services/**/*.test.ts"
],
"require": [
"ts-node/register",
"source-map-support/register",
"./src/test/requires.ts"
],
"recursive": true,
"exit": true
}
-48
View File
@@ -1,48 +0,0 @@
{
"all": true,
"check-coverage": false,
"reporter": [
"text",
"lcov"
],
"include": [
"src/**/*.ts"
],
"exclude": [
"**/*.d.ts",
"**/*.{test,spec}.{js,jsx,ts,tsx,mjs,cjs}",
"**/__tests__/**",
"**/test/**",
"**/tests/**",
"**/.nyc_output/**",
"**/.vscode-test/**",
"**/tests-results/**",
"src/test/**",
"src/generated/**",
"**/node_modules/**",
"**/dist/**",
"**/out/**",
"**/build/**",
"**/coverage/**",
"**/coverage-unit/**",
"**/proto/**",
"**/*.{config,setup}.{js,ts,mjs,cjs}",
"**/vite-env.d.ts",
"**/*.{css,scss,sass,less,styl}",
"**/*.{svg,png,jpg,jpeg,gif,ico}",
"**/*.{json,yaml,yml}"
],
"extension": [
".ts",
".js"
],
"cache": true,
"sourceMap": true,
"instrument": true,
"report-dir": "./coverage-unit"
}
+12 -1
View File
@@ -1,9 +1,20 @@
import { defineConfig } from "@vscode/test-cli"
import path from "path"
const vscodeTestVersion = process.env.VSCODE_TEST_VERSION ?? "stable"
export default defineConfig({
files: "{out/**/*.test.js,src/**/*.test.js,!src/test/e2e/**/*.test.js,!out/src/test/e2e/**/*.test.js}",
files: [
"out/src/{core,test,utils,shared,integrations,hosts,services}/**/*.test.js",
"src/{core,test,utils,shared,integrations,hosts,services}/**/*.test.js",
// The bun unit suite (src/**/__tests__/* and src/test/services/**) runs under
// `bun test` (run-bun-unit-tests.ts) and imports `bun:test`, which this
// Node-based runner cannot load. Exclude it here.
"!out/src/**/__tests__/**/*.test.js",
"!out/src/test/services/**/*.test.js",
"!src/**/__tests__/**/*.test.js",
"!src/test/services/**/*.test.js",
],
mocha: {
ui: "bdd",
timeout: 20000, // Maximum time (in ms) that a test can run before failing
+22 -1
View File
@@ -5,13 +5,31 @@
# Agent tooling, never shipped in the VSIX
.agents/**
.claude/**
.cline/**
.codex/**
CLAUDE.local.md
out/
dist-standalone/
node_modules/
# Nested workspace-member node_modules (bun links these under each package).
# Scoped to the sub-package dirs so it doesn't shadow the top-level
# node_modules/@vscode/codicons re-include below.
webview-ui/node_modules/**
testing-platform/node_modules/**
standalone/**/node_modules/**
src/**
standalone/**
# Build/dev tooling and inputs — bundled into dist/extension.js, not needed in the VSIX.
bunfig.toml
esbuild.mjs
knip.json
biome.jsonc
test-setup.js
.env.example
scripts/**
proto/**
testing-platform/**
tests/**
.gitignore
.yarnrc
esbuild.js
@@ -34,6 +52,7 @@ sdk/**
# only exists if a publish aborts mid-swap; neither should ship in the .vsix.
README.marketplace.md
.README.github.bak
package.json.backup
# Custom
**/demo.gif
@@ -46,7 +65,6 @@ eslint-rules/
old_docs/
evals/
.codespellrc
.mocharc.json
buf.yaml
.clinerules/
@@ -78,6 +96,9 @@ old_docs/**
e2e-build.mjs
e2e.vsix
test-results/
coverage/**
webview-ui/coverage/**
webview-ui/.vite-port
# Ignore Storybook files
**/*.stories.tsx
+21 -6
View File
@@ -1,6 +1,11 @@
{
"root": false,
"root": true,
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"defaultBranch": "main"
},
"assist": {
"enabled": true,
"actions": {
@@ -124,14 +129,18 @@
"!!**/playwright",
"!!**/.vscode-test",
"!!**/test-results",
"!!**/coverage",
"!!**/node_modules",
"!!**/webview-ui/build",
"!!**/generated",
"!!**/proto",
"!!**/tests/specs"
"!!**/tests/specs",
"!!assets/icons/*.svg"
]
},
"plugins": ["src/dev/grit/process-env.grit"],
"plugins": [
"src/dev/grit/process-env.grit"
],
"overrides": [
{
"includes": [
@@ -146,11 +155,15 @@
"!!src/integrations/terminal/**",
"!!src/core/controller/ui/openWalkthrough.ts"
],
"plugins": ["src/dev/grit/vscode-api.grit"]
"plugins": [
"src/dev/grit/vscode-api.grit"
]
},
{
// Do not use console logging directly, use the Logger service instead.
"plugins": ["src/dev/grit/console-log.grit"],
"plugins": [
"src/dev/grit/console-log.grit"
],
"includes": [
"**",
"!!**/esbuild.*",
@@ -183,7 +196,9 @@
"!!src/core/storage/utils/state-helpers.ts",
"!!src/extension.ts"
],
"plugins": ["src/dev/grit/use-cache-service.grit"]
"plugins": [
"src/dev/grit/use-cache-service.grit"
]
}
]
}
+7
View File
@@ -0,0 +1,7 @@
[test]
# Module-substitution aliases for `bun test`. bun resolves tsconfig `paths`
# (@/*, @core/*, @shared/*, …) and the real @cline/llms + @cline/shared dist
# builds on its own; the preload only shadows `vscode` and `@cline/core` with
# their unit-test stubs (mirrors vitest.config.ts resolve.alias). See
# src/test/bun-test-preload.ts for details.
preload = ["./src/test/bun-test-preload.ts"]
-39
View File
@@ -85,44 +85,6 @@ const esbuildProblemMatcherPlugin = {
},
}
const copyWasmFiles = {
name: "copy-wasm-files",
setup(build) {
build.onEnd(() => {
// tree sitter
const sourceDir = path.join(__dirname, "node_modules", "web-tree-sitter")
const targetDir = path.join(__dirname, destDir)
// Copy tree-sitter.wasm
fs.copyFileSync(path.join(sourceDir, "tree-sitter.wasm"), path.join(targetDir, "tree-sitter.wasm"))
// Copy language-specific WASM files
const languageWasmDir = path.join(__dirname, "node_modules", "tree-sitter-wasms", "out")
const languages = [
"typescript",
"tsx",
"python",
"rust",
"javascript",
"go",
"cpp",
"c",
"c_sharp",
"ruby",
"java",
"php",
"swift",
"kotlin",
]
languages.forEach((lang) => {
const filename = `tree-sitter-${lang}.wasm`
fs.copyFileSync(path.join(languageWasmDir, filename), path.join(targetDir, filename))
})
})
},
}
const buildEnvVars = {
"import.meta.url": "_importMetaUrl",
"process.env.IS_STANDALONE": JSON.stringify(standalone ? "true" : "false"),
@@ -176,7 +138,6 @@ const baseConfig = {
define: buildEnvVars,
tsconfig: path.resolve(__dirname, "tsconfig.json"),
plugins: [
copyWasmFiles,
aliasResolverPlugin,
/* add to the end of plugins array */
esbuildProblemMatcherPlugin,
+32 -21
View File
@@ -1,23 +1,34 @@
{
"entry": [
"src/extension.ts",
"src/standalone/cline-core.ts",
"src/generated/hosts/standalone/protobus-server-setup.ts",
"src/generated/hosts/standalone/host-bridge-clients.ts",
"src/generated/hosts/vscode/protobus-services.ts",
"src/generated/hosts/vscode/hostbridge-grpc-service-config.ts"
],
"project": [
"src/**/*.ts"
],
"ignore": [
"out/**",
"node_modules/**",
"*.d.ts",
"**/*.test.ts",
"**/__tests__",
"src/test/**",
"src/shared/**"
],
"vite": true
"$schema": "https://unpkg.com/knip@5/schema.json",
"workspaces": {
".": {
"entry": [
"src/extension.ts",
"src/standalone/cline-core.ts",
"src/generated/hosts/standalone/protobus-server-setup.ts",
"src/generated/hosts/standalone/host-bridge-clients.ts",
"src/generated/hosts/vscode/protobus-services.ts",
"src/generated/hosts/vscode/hostbridge-grpc-service-config.ts",
"src/**/*.test.ts",
"src/**/__tests__/**/*.ts",
"src/test/**/*.ts"
],
"project": [
"src/**/*.ts"
]
},
"webview-ui": {
"entry": [
"src/services/grpc-client.ts",
"src/**/*.test.{ts,tsx}",
"src/**/*.spec.{ts,tsx}",
"src/**/__tests__/**/*.{ts,tsx}"
],
"project": [
"src/**/*.{ts,tsx}",
"*.ts"
],
"vite": true
}
}
}
-21850
View File
File diff suppressed because it is too large Load Diff
+67 -109
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "4.0.4",
"version": "3.89.0",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
@@ -89,7 +89,7 @@
{
"id": "mcp",
"title": "Extend with Powerful Tools (MCP)",
"description": "Connect to databases, APIs, or discover new capabilities in the MCP Marketplace.",
"description": "Connect to databases, APIs, and other external tools through MCP.",
"media": {
"markdown": "walkthrough/step4.md"
}
@@ -229,26 +229,9 @@
"command": "cline.reconstructTaskHistory",
"title": "Reconstruct Task History",
"category": "Cline"
},
{
"command": "cline.reviewComment.reply",
"title": "Reply",
"category": "Cline",
"enablement": "!commentIsEmpty"
},
{
"command": "cline.reviewComment.addToChat",
"title": "Add to Cline Chat",
"category": "Cline",
"icon": "$(link-external)"
}
],
"keybindings": [
{
"command": "editor.action.submitComment",
"key": "enter",
"when": "commentEditorFocused && commentController == cline-ai-review && !commentIsEmpty"
},
{
"command": "cline.addToChat",
"key": "cmd+'",
@@ -350,24 +333,6 @@
{
"command": "cline.abortGitCommitMessage",
"when": "config.git.enabled && cline.isGeneratingCommit"
},
{
"command": "cline.reviewComment.reply",
"when": "false"
}
],
"comments/commentThread/context": [
{
"command": "cline.reviewComment.reply",
"group": "inline",
"when": "commentController == cline-ai-review"
}
],
"comments/commentThread/title": [
{
"command": "cline.reviewComment.addToChat",
"group": "inline",
"when": "commentController == cline-ai-review"
}
]
},
@@ -377,67 +342,74 @@
}
},
"scripts": {
"vscode:prepublish": "npm run package",
"compile": "npm run check-types && npm run lint && node esbuild.mjs",
"compile-standalone": "npm run check-types && npm run lint && node esbuild.mjs --standalone",
"vscode:prepublish": "bun run package",
"compile": "bun run check-types && bun run lint && bun esbuild.mjs",
"compile-standalone": "bun run check-types && bun run lint && bun esbuild.mjs --standalone",
"postcompile-standalone": "node scripts/package-standalone.mjs",
"dev": "npm run protos && npm run watch",
"watch": "npx npm-run-all -p watch:*",
"watch:esbuild": "node esbuild.mjs --watch",
"dev": "bun run protos && bun run watch",
"watch": "bun run --parallel watch:esbuild watch:tsc",
"watch:esbuild": "bun esbuild.mjs --watch",
"watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
"package": "npm run check-types && npm run build:webview && npm run lint && node esbuild.mjs --production",
"package": "bun run check-types && bun run build:webview && bun run lint && bun esbuild.mjs --production",
"protos": "node scripts/build-proto.mjs",
"models": "node scripts/generate-models-dev-catalog.mjs",
"protos-python": "node scripts/build-python-proto.mjs",
"download-ripgrep": "node scripts/download-ripgrep.mjs",
"postprotos": "biome format --config-path ./biome.jsonc src/shared/proto src/generated webview-ui/src/services/grpc-client.ts --write --no-errors-on-unmatched",
"postprotos": "biome format --config-path ./biome.jsonc src/shared/proto src/core/controller src/hosts/ webview-ui/src/services src/generated --write --no-errors-on-unmatched",
"clean:build": "rimraf dist dist-standalone webview-ui/build src/generated out/",
"clean:deps": "rimraf node_modules webview-ui/node_modules",
"clean:all": "npm run clean:build && npm run clean:deps",
"clean:all": "bun run clean:build && bun run clean:deps",
"compile-tests": "node ./scripts/build-tests.js",
"watch-tests": "tsc -p . -w --outDir out",
"check-types": "npm run protos && tsc --noEmit && cd webview-ui && tsc --noEmit",
"lint": "biome lint --config-path ./biome.jsonc --no-errors-on-unmatched --files-ignore-unknown=true --diagnostic-level=error && npm run lint:proto",
"check-types": "bun run protos && bunx tsc --noEmit && cd webview-ui && bunx tsc --noEmit",
"lint": "biome lint --config-path ./biome.jsonc --no-errors-on-unmatched --files-ignore-unknown=true --diagnostic-level=error && bun run lint:proto",
"lint:proto": "bash ./scripts/proto-lint.sh",
"format": "biome format --config-path ./biome.jsonc --changed --since main --no-errors-on-unmatched --files-ignore-unknown=true --diagnostic-level=error",
"format:fix": "biome check --config-path ./biome.jsonc --changed --since main --no-errors-on-unmatched --files-ignore-unknown=true --write",
"fix:all": "biome check --config-path ./biome.jsonc --no-errors-on-unmatched --files-ignore-unknown=true --write --diagnostic-level=error --unsafe",
"ci:check-all": "npx npm-run-all -p check-types lint format",
"ci:build": "npm run protos && npm run build:webview && node esbuild.mjs && npm run compile-tests",
"pretest": "npm run compile && npm run compile-tests && npm run compile-standalone && npm run lint",
"test": "npx npm-run-all test:unit test:integration",
"test:integration": "vscode-test",
"test:unit": "cross-env TS_NODE_PROJECT=./tsconfig.unit-test.json mocha",
"test:coverage": "vscode-test --coverage",
"test:sca-server": "npx tsx watch scripts/test-standalone-core-api-server.ts",
"test:tp-orchestrator": "npx tsx scripts/testing-platform-orchestrator.ts",
"analyze:unused": "bunx --yes knip --config knip.json --include files,exports,types,enumMembers,duplicates",
"analyze:unused:prod": "bunx --yes knip --config knip.json --production --include files,exports,types,enumMembers,dependencies",
"analyze:unused:fix-exports": "node scripts/remove-unused-exports.mjs --apply",
"analyze:unused:fix-exports:dry": "node scripts/remove-unused-exports.mjs",
"format": "biome format --config-path ./biome.jsonc --changed --since main --no-errors-on-unmatched --files-ignore-unknown=true --diagnostic-level=error --semicolons=as-needed",
"format:fix": "biome check --config-path ./biome.jsonc --changed --since main --no-errors-on-unmatched --files-ignore-unknown=true --write --semicolons=as-needed",
"fix:all": "biome check --config-path ./biome.jsonc --no-errors-on-unmatched --files-ignore-unknown=true --write --diagnostic-level=error --unsafe --semicolons=as-needed",
"ci:check-all": "bun run --parallel check-types lint format",
"ci:build": "bun run protos && bun run build:webview && bun esbuild.mjs && bun run compile-tests",
"pretest": "bun run compile && bun run compile-tests && bun run compile-standalone && bun run lint",
"test": "bun run test:unit && bun run test:integration",
"test:integration": "bun run compile-tests && vscode-test",
"test:unit": "bun scripts/run-bun-unit-tests.ts",
"test:vitest": "vitest run --config vitest.config.ts",
"test:vitest:watch": "vitest --config vitest.config.ts",
"test:bun": "bun scripts/run-bun-tests.ts",
"test:bun:unit": "bun scripts/run-bun-unit-tests.ts",
"test:coverage": "bun run compile-tests && vscode-test --coverage",
"test:sca-server": "bun --watch scripts/test-standalone-core-api-server.ts",
"test:tp-orchestrator": "bun scripts/testing-platform-orchestrator.ts",
"dev:mcp-oauth-test-server": "bun src/dev/mcp-oauth-test-server/server.ts",
"e2e": "playwright test -c playwright.config.ts",
"test:e2e:build": "vsce package --allow-package-secrets sendgrid --out dist/e2e.vsix",
"test:e2e": "playwright install && npm run test:e2e:build && node src/test/e2e/utils/build.mjs && playwright test",
"test:e2e:optimal": "npm run test:e2e:build && node src/test/e2e/utils/build.mjs && playwright test",
"test:e2e:ui": "npx tsx scripts/interactive-playwright.ts",
"install:all": "npm install && cd webview-ui && npm install",
"dev:webview": "cd webview-ui && npm run dev",
"build:webview": "cd webview-ui && npm run build",
"test:webview": "cd webview-ui && npm run test",
"test:e2e:build": "vsce package --no-dependencies --allow-package-secrets sendgrid --out dist/e2e.vsix",
"test:e2e": "playwright install && bun run test:e2e:build && node src/test/e2e/utils/build.mjs && playwright test",
"test:e2e:optimal": "bun run test:e2e:build && node src/test/e2e/utils/build.mjs && playwright test",
"test:e2e:ui": "bun scripts/interactive-playwright.ts",
"install:all": "bun install",
"dev:webview": "cd webview-ui && bun run dev",
"build:webview": "cd webview-ui && bun run build",
"test:webview": "cd webview-ui && bun run test",
"publish:marketplace": "node scripts/publish-marketplace.mjs",
"publish:marketplace:prerelease": "node scripts/publish-marketplace.mjs --pre-release",
"publish:marketplace:nightly": "node ./scripts/publish-nightly.mjs",
"prepare": "npx husky",
"docs": "cd docs && npm run dev",
"docs:check-links": "cd docs && npm run check",
"docs:rename-file": "cd docs && npm run rename",
"docs": "cd docs && bun run dev",
"docs:check-links": "cd docs && bun run check",
"docs:rename-file": "cd docs && bun run rename",
"report-issue": "node scripts/report-issue.js",
"storybook": "cd webview-ui && npm run storybook",
"eval:smoke:run": "npx tsx evals/smoke-tests/run-smoke-tests.ts"
"storybook": "cd webview-ui && bun run storybook",
"eval:smoke:run": "bun evals/smoke-tests/run-smoke-tests.ts"
},
"lint-staged": {
"src/shared/storage/state-keys.ts": [
"node scripts/generate-state-proto.mjs",
"git add proto/cline/state.proto"
"git add apps/vscode/proto/cline/state.proto"
],
"*": [
"biome check --write --staged --no-errors-on-unmatched --files-ignore-unknown=true"
"biome check --write --staged --no-errors-on-unmatched --files-ignore-unknown=true --semicolons=as-needed"
]
},
"devDependencies": {
@@ -453,7 +425,6 @@
"@types/node": "20.x",
"@types/pdf-parse": "^1.1.4",
"@types/picomatch": "^4.0.2",
"@types/proxyquire": "^1.3.31",
"@types/shell-quote": "^1.7.5",
"@types/should": "^11.2.0",
"@types/sinon": "^21.0.0",
@@ -465,42 +436,41 @@
"c8": "^10.1.3",
"chai": "^4.3.10",
"chalk": "5.6.2",
"cross-env": "^10.1.0",
"dotenv": "^17.2.3",
"esbuild": "^0.25.0",
"glob": "^11.0.0",
"grpc-tools": "^1.13.0",
"husky": "^9.1.7",
"lint-staged": "^16.1.0",
"minimatch": "^3.0.3",
"npm-run-all": "^4.1.5",
"nyc": "^17.1.0",
"minimist": "^1.2.8",
"mocha": "^11.7.4",
"playwright": "^1.55.1",
"prebuild-install": "^7.1.3",
"protoc-gen-ts": "^0.8.7",
"proxyquire": "^2.1.3",
"rimraf": "^6.0.1",
"should": "^13.2.3",
"sinon": "^21.0.3",
"tar": "^7.5.2",
"tree-kill": "^1.2.2",
"ts-node": "^10.9.2",
"ts-proto": "^2.6.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.4.5"
"typescript": "^5.4.5",
"vitest": "^4.0.17"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.50.4",
"@anthropic-ai/vertex-sdk": "^0.11.5",
"@aws-sdk/client-bedrock-runtime": "^3.922.0",
"@aws-sdk/credential-providers": "^3.922.0",
"@azure/identity": "^4.13.0",
"@anthropic-ai/sdk": "^0.37.0",
"@bufbuild/protobuf": "^2.2.5",
"@cerebras/cerebras_cloud_sdk": "^1.35.0",
"@google-cloud/vertexai": "^1.9.3",
"@cline/agents": "workspace:*",
"@cline/core": "workspace:*",
"@cline/llms": "workspace:*",
"@cline/shared": "workspace:*",
"@google/genai": "^1.30.0",
"@grpc/grpc-js": "^1.9.15",
"@grpc/proto-loader": "^0.7.13",
"@grpc/reflection": "^1.0.4",
"@mistralai/mistralai": "^1.5.0",
"@modelcontextprotocol/sdk": "^1.25.1",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.56.0",
"@opentelemetry/core": "^2.1.0",
"@opentelemetry/exporter-logs-otlp-grpc": "^0.56.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.56.0",
@@ -520,9 +490,6 @@
"@opentelemetry/sdk-trace-node": "^1.30.1",
"@opentelemetry/semantic-conventions": "^1.37.0",
"@playwright/test": "^1.55.1",
"@sap-ai-sdk/ai-api": "^2.7.0",
"@sap-ai-sdk/orchestration": "^2.7.0",
"@sap-cloud-sdk/connectivity": "^4.6.0",
"@streamparser/json": "^0.0.22",
"@tailwindcss/vite": "^4.1.14",
"@types/uuid": "^10.0.0",
@@ -548,13 +515,15 @@
"ignore": "^7.0.3",
"image-size": "^2.0.2",
"isbinaryfile": "^5.0.2",
"js-yaml": "^4.1.1",
"jschardet": "^3.1.4",
"json5": "^2.2.3",
"jwt-decode": "^4.0.0",
"mammoth": "^1.11.0",
"nanoid": "^5.1.6",
"nice-grpc": "^2.1.12",
"nice-grpc-common": "^2.0.3",
"node-machine-id": "^1.1.12",
"ollama": "^0.5.13",
"open": "^10.1.2",
"open-graph-scraper": "^6.9.0",
"openai": "^6.21.0",
@@ -573,24 +542,13 @@
"simple-git": "3.36.0",
"strip-ansi": "^7.1.2",
"tailwindcss": "^4.1.14",
"tree-sitter-wasms": "^0.1.11",
"ts-morph": "^25.0.1",
"turndown": "^7.2.0",
"ulid": "^2.4.0",
"undici": "^7.26.0",
"uuid": "^11.1.0",
"vscode-uri": "^3.1.0",
"web-tree-sitter": "^0.22.6",
"zod": "^3.24.2"
},
"overrides": {
"tar-fs": ">=3.1.1",
"tar": "^7.5.2",
"vite": "^7.1.11",
"js-yaml": "^4.1.1",
"serialize-javascript": ">=7.0.3",
"protobufjs": "7.5.8",
"diff": "8.0.4"
"zod": "^4.3.6"
},
"c8": {
"reporter": [
-42
View File
@@ -12,16 +12,11 @@ service McpService {
rpc toggleMcpServer(ToggleMcpServerRequest) returns (McpServers);
rpc updateMcpTimeout(UpdateMcpTimeoutRequest) returns (McpServers);
rpc addRemoteMcpServer(AddRemoteMcpServerRequest) returns (McpServers);
rpc downloadMcp(StringRequest) returns (McpDownloadResponse);
rpc restartMcpServer(StringRequest) returns (McpServers);
rpc deleteMcpServer(StringRequest) returns (McpServers);
rpc toggleToolAutoApprove(ToggleToolAutoApproveRequest) returns (McpServers);
rpc refreshMcpMarketplace(EmptyRequest) returns (McpMarketplaceCatalog);
rpc openMcpSettings(EmptyRequest) returns (Empty);
rpc authenticateMcpServer(StringRequest) returns (Empty);
// Subscribe to MCP marketplace catalog updates
rpc subscribeToMcpMarketplaceCatalog(EmptyRequest) returns (stream McpMarketplaceCatalog);
rpc getLatestMcpServers(Empty) returns (McpServers);
// Subscribe to MCP server updates
@@ -114,40 +109,3 @@ message McpServer {
message McpServers {
repeated McpServer mcp_servers = 1;
}
message McpMarketplaceItem {
string mcp_id = 1;
string github_url = 2;
string name = 3;
string author = 4;
string description = 5;
string codicon_icon = 6;
string logo_url = 7;
string category = 8;
repeated string tags = 9;
bool requires_api_key = 10;
optional string readme_content = 11;
optional string llms_installation_content = 12;
bool is_recommended = 13;
int32 github_stars = 14;
int32 download_count = 15;
string created_at = 16;
string updated_at = 17;
string last_github_sync = 18;
}
message McpMarketplaceCatalog {
repeated McpMarketplaceItem items = 1;
}
message McpDownloadResponse {
string mcp_id = 1;
string github_url = 2;
string name = 3;
string author = 4;
string description = 5;
string readme_content = 6;
string llms_installation_content = 7;
bool requires_api_key = 8;
optional string error = 9;
}
+154 -2
View File
@@ -21,8 +21,6 @@ service ModelsService {
rpc refreshOpenRouterModelsRpc(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
// Refreshes and returns recommended and free Cline models
rpc refreshClineRecommendedModelsRpc(EmptyRequest) returns (ClineRecommendedModelsResponse);
// Refreshes and returns Cline provider models
rpc refreshClineModelsRpc(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
// Refreshes and returns Hugging Face models
rpc refreshHuggingFaceModels(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
// Refreshes and returns OpenAI models
@@ -55,6 +53,18 @@ service ModelsService {
rpc getAihubmixModels(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
// Refreshes and returns Vercel AI Gateway models
rpc refreshVercelAiGatewayModelsRpc(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
// Lists providers available from the unified SDK-backed catalog
rpc listProviders(Empty) returns (ProviderListingsResponse);
// Resolves model metadata for a provider through the unified SDK-backed catalog
rpc resolveProviderModels(ResolveProviderModelsRequest) returns (ProviderModelsResponse);
// Resolves model metadata for a provider/model without refreshing model lists
rpc resolveModelInfo(ResolveModelInfoRequest) returns (ResolveModelInfoResponse);
// Reads redacted effective provider configuration
rpc readProviderConfig(StringRequest) returns (ProviderConfigResponse);
// Writes provider configuration fields and returns redacted effective configuration
rpc writeProviderConfig(WriteProviderConfigRequest) returns (ProviderConfigResponse);
// Commits a mode-specific model selection atomically with its model metadata
rpc commitModelSelection(CommitModelSelectionRequest) returns (Empty);
}
// List of VS Code LM models
@@ -117,6 +127,142 @@ message OpenRouterCompatibleModelInfo {
map<string, OpenRouterModelInfo> models = 1;
}
// Lightweight provider entry for the top-level model/provider picker.
// Does not include the full model list; use resolveProviderModels for models.
message ProviderListing {
string id = 1;
string name = 2;
optional string default_model_id = 3;
optional string family = 4;
optional string protocol = 5;
optional string auth_description = 6;
optional string base_url_description = 7;
bool allows_custom_model_ids = 8;
// SDK-driven hint for cost display. Values: "show" (default) or "hide".
// Sourced from `resolveProviderUsageCostDisplay(provider.metadata)` in
// `@cline/llms`. When "hide", consumers must suppress per-token pricing
// and total cost displays (matches the CLI's `shouldShowCliUsageCost`).
string usage_cost_display = 11;
}
message ProviderListingsResponse {
repeated ProviderListing providers = 1;
}
message ResolveProviderModelsRequest {
string provider_id = 1;
bool force_refresh = 2;
optional string request_id = 3;
}
message CatalogErrorInfo {
string kind = 1;
string message = 2;
optional string code = 3;
optional bool retryable = 4;
}
message ProviderModelsResponse {
string provider_id = 1;
string request_id = 2;
string config_fingerprint = 3;
int64 fetched_at = 4;
bool ok = 5;
map<string, OpenRouterModelInfo> models = 6;
optional string default_model_id = 7;
optional string source = 8;
optional CatalogErrorInfo error = 9;
}
message ResolveModelInfoRequest {
string provider_id = 1;
optional string model_id = 2;
}
message ResolveModelInfoResponse {
string provider_id = 1;
string model_id = 2;
optional OpenRouterModelInfo model_info = 3;
string source = 4;
}
message AwsProviderConfig {
optional string authentication = 1;
optional string profile = 2;
optional string access_key = 3;
int64 access_key_length = 4;
optional string secret_key = 5;
int64 secret_key_length = 6;
optional string session_token = 7;
int64 session_token_length = 8;
optional string endpoint = 9;
optional bool use_prompt_cache = 10;
optional string custom_model_base_id = 11;
optional bool use_cross_region_inference = 12;
optional bool use_global_inference = 13;
}
message GcpProviderConfig {
optional string project_id = 1;
optional string region = 2;
}
message ProviderConfigResponse {
string provider_id = 1;
optional string base_url = 2;
optional string api_line = 3;
map<string, string> headers = 4;
optional string region = 5;
int64 api_key_length = 6;
bool has_access_token = 7;
bool has_refresh_token = 8;
optional string account_id = 9;
optional CommittedModelSelection plan_selection = 10;
optional CommittedModelSelection act_selection = 11;
optional AwsProviderConfig aws = 12;
optional GcpProviderConfig gcp = 13;
}
message CommittedModelSelection {
string provider_id = 1;
string model_id = 2;
OpenRouterModelInfo model_info = 3;
}
message ProviderReasoningPatch {
optional bool enabled = 1;
optional string effort = 2; // "none" | "low" | "medium" | "high" | "xhigh"
optional int32 budget_tokens = 3;
}
message WriteProviderConfigPatch {
optional string api_key = 1;
optional string base_url = 2;
map<string, string> headers = 3;
optional string region = 4;
optional string api_line = 5;
optional string access_token = 6;
optional string refresh_token = 7;
optional string account_id = 8;
optional ProviderReasoningPatch reasoning = 9;
optional bool clear_headers = 10;
optional AwsProviderConfig aws = 11;
optional GcpProviderConfig gcp = 12;
}
message WriteProviderConfigRequest {
string provider_id = 1;
WriteProviderConfigPatch patch = 2;
}
message CommitModelSelectionRequest {
string provider_id = 1;
string mode = 2;
string model_id = 3;
OpenRouterModelInfo model_info = 4;
}
message ClineRecommendedModel {
string id = 1;
string name = 2;
@@ -467,6 +613,12 @@ enum ApiProvider {
OPENAI_CODEX = 40;
WANDB = 41;
CLINE_PASS = 42;
POOLSIDE = 45;
V0 = 46;
XIAOMI = 47;
ZAI_CODING_PLAN = 49;
reserved 43, 44, 48;
reserved "OPENAI_CODEX_CLI", "OPENCODE", "KILO";
}
enum ApiFormat {
@@ -0,0 +1,32 @@
syntax = "proto3";
package cline;
import "cline/common.proto";
option go_package = "github.com/cline/grpc-go/cline";
option java_multiple_files = true;
option java_package = "bot.cline.proto";
enum RemoteConfigType {
RULE = 0;
WORKFLOW = 1;
SKILL = 2;
}
message RemoteConfigSetting {
RemoteConfigType type = 1;
string name = 2;
string content = 3;
bool enabled = 4;
bool locked = 5;
}
message RemoteConfigSettingsResponse {
repeated RemoteConfigSetting settings = 1;
}
service RemoteConfigService {
rpc getRemoteConfigSettings(Empty) returns (RemoteConfigSettingsResponse);
rpc toggleRemoteConfigSetting(StringRequest) returns (RemoteConfigSetting);
}
+1 -1
View File
@@ -23,7 +23,7 @@ message SlashCommandInfo {
string name = 1; // Command name without slash, e.g., "newtask", "smol"
string description = 2; // Human-readable description
string section = 3; // "default", "custom", or "cli"
bool cli_compatible = 4; // false for VS Code-only commands like explain-changes
bool cli_compatible = 4; // false for VS Code-only commands
}
// Response containing all available slash commands
+7 -4
View File
@@ -250,7 +250,6 @@ message Settings {
optional string default_terminal_profile = 137;
optional int32 terminal_output_line_limit = 138;
optional int32 max_consecutive_mistakes = 139;
optional bool strict_plan_mode_enabled = 141;
optional bool yolo_mode_toggled = 142;
optional bool use_auto_condense = 143;
optional bool cline_web_tools_enabled = 144;
@@ -286,7 +285,6 @@ message Settings {
optional string act_mode_cline_model_id = 180;
optional OpenRouterModelInfo act_mode_cline_model_info = 181;
optional bool show_feature_tips = 182;
optional bool lazy_teammate_mode_enabled = 183;
}
message State {
@@ -391,6 +389,7 @@ message UpdateSettingsRequest {
reserved 15; // was openai_reasoning_effort (moved to mode-scoped reasoning effort)
reserved 23; // was dictation_settings (dictation removed)
reserved 38; // was skills_enabled (removed - now always enabled)
reserved 43; // was lazy_teammate_mode_enabled (removed)
Metadata metadata = 1;
optional ModelsApiConfiguration api_configuration = 2;
@@ -405,7 +404,7 @@ message UpdateSettingsRequest {
optional int32 terminal_output_line_limit = 12;
optional PlanActMode mode = 13;
optional string preferred_language = 14;
optional bool strict_plan_mode_enabled = 16;
reserved 16; // was strict_plan_mode_enabled (removed)
optional FocusChainSettings focus_chain_settings = 17;
optional bool use_auto_condense = 18;
optional string custom_prompt = 19;
@@ -429,7 +428,6 @@ message UpdateSettingsRequest {
optional bool worktrees_enabled = 40;
optional bool double_check_completion_enabled = 41;
optional bool show_feature_tips = 42;
optional bool lazy_teammate_mode_enabled = 43;
}
message UpdateTerminalConnectionTimeoutRequest {
@@ -456,6 +454,11 @@ message OnboardingProgressRequest {
optional string action = 2;
optional bool completed = 3;
optional string model_selected = 4;
optional string page = 5;
optional string page_variant = 6;
optional string user_type = 7;
optional int32 destination_step = 8;
optional string destination_page = 9;
}
message OnboardingModelGroup {
+15 -10
View File
@@ -32,6 +32,8 @@ service TaskService {
rpc getTaskHistory(GetTaskHistoryRequest) returns (TaskHistoryArray);
// Sends a response to a previous ask operation
rpc askResponse(AskResponseRequest) returns (Empty);
// Edits a previous user message, truncates following conversation, and regenerates
rpc editMessageAndRegenerate(EditMessageAndRegenerateRequest) returns (Empty);
// Records task feedback (thumbs up/down)
rpc taskFeedback(StringRequest) returns (Empty);
// Shows task completion changes diff in a view
@@ -40,8 +42,6 @@ service TaskService {
rpc executeQuickWin(ExecuteQuickWinRequest) returns (Empty);
// Deletes all task history
rpc deleteAllTaskHistory(EmptyRequest) returns (DeleteAllTaskHistoryCount);
// Explains changes with AI and adds inline comments to the diff view
rpc explainChanges(ExplainChangesRequest) returns (Empty);
}
// Request message for creating a new task
@@ -82,12 +82,14 @@ message GetTaskHistoryRequest {
string search_query = 3;
string sort_by = 4;
bool current_workspace_only = 5;
int32 limit = 6;
int32 offset = 7;
}
// Response for task history
message TaskHistoryArray {
repeated TaskItem tasks = 1;
int32 total_count = 2;
bool has_more = 2;
}
// Task item details for history list
@@ -114,6 +116,16 @@ message AskResponseRequest {
repeated string files = 5;
}
// Request for editing a past user message and regenerating the conversation after it
message EditMessageAndRegenerateRequest {
Metadata metadata = 1;
int64 message_ts = 2;
string text = 3;
repeated string images = 4;
repeated string files = 5;
bool restore_workspace = 6;
}
// Request for executing a quick win task
message ExecuteQuickWinRequest {
Metadata metadata = 1;
@@ -125,10 +137,3 @@ message ExecuteQuickWinRequest {
message DeleteAllTaskHistoryCount {
int32 tasks_deleted = 1;
}
// Request for explaining changes with AI
message ExplainChangesRequest {
Metadata metadata = 1;
// Timestamp of the completion message to explain changes for
int64 message_ts = 2;
}
+6 -1
View File
@@ -67,7 +67,6 @@ enum ClineSay {
INFO = 26;
TASK_PROGRESS = 27;
ERROR_RETRY = 28;
GENERATE_EXPLANATION = 29;
HOOK_STATUS = 30;
HOOK_OUTPUT_STREAM = 31;
COMMAND_PERMISSION_DENIED = 32;
@@ -226,6 +225,12 @@ message ClineMessage {
ClineAskNewTask ask_new_task = 21;
ClineApiReqInfo api_req_info = 22;
ClineModelInfo model_info = 23;
// Convergent-replica fields (see webview-message-state-design.md):
// seq = monotonic freshness (higher seq wins for the same ts/identity)
// epoch = conversation/replica fence (older epoch is dropped by the webview)
int64 seq = 24;
int64 epoch = 25;
}
message ShowWebviewEvent {
+20 -3
View File
@@ -34,9 +34,26 @@ const GRPC_JS_OUT_DIR = path.resolve("src/generated/grpc-js")
const NICE_JS_OUT_DIR = path.resolve("src/generated/nice-grpc")
const DESCRIPTOR_OUT_DIR = path.resolve("dist-standalone/proto")
const TS_PROTO_PLUGIN = isWindows
? path.resolve("node_modules/.bin/protoc-gen-ts_proto.cmd") // Use the .bin directory path for Windows
: require.resolve("ts-proto/protoc-gen-ts_proto")
// protoc invokes the ts-proto plugin as a child process, so it needs a path it can
// directly execute. On POSIX the package's JS bin (with its shebang) works. On
// Windows protoc cannot exec a bare .js or bun's `.bunx` shim ("%1 is not a valid
// Win32 application"), and the package manager's `.cmd` shim location/name varies
// (npm vs bun's hoisted store). To be package-manager-agnostic, generate a tiny
// .cmd wrapper that runs the resolved plugin JS via `node`.
function resolveTsProtoPlugin() {
const pluginJs = require.resolve("ts-proto/protoc-gen-ts_proto")
if (!isWindows) {
return pluginJs
}
const wrapperDir = path.resolve("dist-standalone")
fsSync.mkdirSync(wrapperDir, { recursive: true })
const wrapperPath = path.join(wrapperDir, "protoc-gen-ts_proto.cmd")
// %* forwards protoc's plugin args/stdio to the JS entry run under node.
fsSync.writeFileSync(wrapperPath, `@echo off\r\nnode "${pluginJs}" %*\r\n`)
return wrapperPath
}
const TS_PROTO_PLUGIN = resolveTsProtoPlugin()
const TS_PROTO_OPTIONS = [
"env=both",
+39 -1
View File
@@ -1,5 +1,7 @@
#!/usr/bin/env node
const { execSync } = require("child_process")
const fs = require("fs")
const path = require("path")
const esbuild = require("esbuild")
const watch = process.argv.includes("--watch")
@@ -53,7 +55,43 @@ async function main() {
}
}
execSync("tsc -p ./tsconfig.test.json --outDir out", { encoding: "utf-8" })
// tsc does not delete output for source/tests that were removed or are no longer
// part of tsconfig.test.json. The VS Code test runner globs out/src/**/*.test.js,
// so stale compiled tests can still run unless we clear the test build output first.
fs.rmSync(path.join(__dirname, "..", "out", "src"), { recursive: true, force: true })
fs.rmSync(path.join(__dirname, "..", "out", "packages"), { recursive: true, force: true })
// Single source of truth for the bun-vs-integration test split: any *.test.ts that
// imports from "bun:test" is owned by the bun runner (scripts/run-bun-unit-tests.ts)
// and must NOT be compiled into the Node-based @vscode/test-cli `out/` tree (Node
// cannot load the `bun:test` builtin, and these files use bun-only APIs like
// `mock.module` / 3-arg `it`). Generate a tsconfig that excludes them so the
// integration compile only ever sees mocha-owned tests.
const projectRoot = path.join(__dirname, "..")
const bunTestImport = /from\s+["']bun:test["']/
function collectBunTestFiles(dir, acc) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === "node_modules") continue
const full = path.join(dir, entry.name)
if (entry.isDirectory()) {
collectBunTestFiles(full, acc)
} else if (entry.isFile() && entry.name.endsWith(".test.ts")) {
if (bunTestImport.test(fs.readFileSync(full, "utf-8"))) {
acc.push(path.relative(projectRoot, full).split(path.sep).join("/"))
}
}
}
return acc
}
const bunOwnedTests = collectBunTestFiles(path.join(projectRoot, "src"), [])
// tsconfig.test.json is JSONC (contains comments); parse with json5 (a project dep).
const JSON5 = require("json5")
const baseTestConfig = JSON5.parse(fs.readFileSync(path.join(projectRoot, "tsconfig.test.json"), "utf-8"))
baseTestConfig.exclude = [...(baseTestConfig.exclude ?? []), ...bunOwnedTests]
const generatedConfigPath = path.join(projectRoot, "tsconfig.test.generated.json")
fs.writeFileSync(generatedConfigPath, JSON.stringify(baseTestConfig, null, "\t"))
execSync(`tsc -p ${JSON.stringify(generatedConfigPath)} --outDir out`, { encoding: "utf-8" })
main().catch((e) => {
console.error(e)
+129
View File
@@ -0,0 +1,129 @@
// Dead-source finder: uses esbuild's own bundle reachability (the same analysis
// that drives tree-shaking + minification mangling) to compute which src/ files
// are reachable from BOTH shipped entry points:
// - src/extension.ts (VS Code extension host)
// - src/standalone/cline-core.ts (standalone host used by JetBrains + CLI)
//
// A src/*.ts file that is NOT in the union of metafile inputs for those two
// builds is unreachable from any shipped entry => dead (modulo dynamic import()
// of computed specifiers, which esbuild surfaces separately).
//
// Run: node scripts/find-dead-src.mjs
import fs from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import * as esbuild from "esbuild"
import { glob } from "glob"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const root = path.resolve(__dirname, "..")
const aliases = {
"@": path.join(root, "src"),
"@core": path.join(root, "src/core"),
"@integrations": path.join(root, "src/integrations"),
"@services": path.join(root, "src/services"),
"@shared": path.join(root, "src/shared"),
"@utils": path.join(root, "src/utils"),
"@packages": path.join(root, "src/packages"),
}
const aliasResolverPlugin = {
name: "alias-resolver",
setup(build) {
for (const [alias, aliasPath] of Object.entries(aliases)) {
const aliasRegex = new RegExp(`^${alias}($|/.*)`)
build.onResolve({ filter: aliasRegex }, (args) => {
const importPath = args.path.replace(alias, aliasPath)
const exts = [".ts", ".tsx", ".js", ".jsx"]
if (fs.existsSync(importPath)) {
const stats = fs.statSync(importPath)
if (stats.isDirectory()) {
for (const ext of exts) {
const idx = path.join(importPath, `index${ext}`)
if (fs.existsSync(idx)) return { path: idx }
}
} else {
return { path: importPath }
}
}
for (const ext of exts) {
if (fs.existsSync(`${importPath}${ext}`)) return { path: `${importPath}${ext}` }
}
return undefined
})
}
},
}
const common = {
bundle: true,
minify: false,
sourcemap: false,
logLevel: "silent",
format: "cjs",
platform: "node",
metafile: true,
write: false,
absWorkingDir: root,
tsconfig: path.join(root, "tsconfig.json"),
packages: "external",
plugins: [aliasResolverPlugin],
define: { "process.env.IS_DEV": "false", "process.env.IS_TEST": "false" },
banner: { js: "const _importMetaUrl=require('url').pathToFileURL(__filename)" },
}
async function inputsFor(entry, external) {
const r = await esbuild.build({ ...common, entryPoints: [entry], external })
return new Set(Object.keys(r.metafile.inputs).filter((f) => f.startsWith("src/") && /\.tsx?$/.test(f)))
}
const ext = await inputsFor("src/extension.ts", ["vscode"])
const standalone = await inputsFor("src/standalone/cline-core.ts", [
"vscode",
"@grpc/reflection",
"grpc-health-check",
"better-sqlite3",
])
const live = new Set([...ext, ...standalone])
// Third consumer: the webview (webview-ui/) is a separate Vite/React build that
// imports extension code ONLY from src/shared (via "@shared/*" alias or relative
// "../src/shared/*" paths). Any src/shared file referenced from webview-ui/src is
// therefore live even if the extension-host/standalone bundles don't reach it.
// Conservatively mark every src/shared file mentioned by the webview as live.
const webviewFiles = await glob("webview-ui/src/**/*.{ts,tsx}", { cwd: root })
const sharedMentionedByWebview = new Set()
for (const wf of webviewFiles) {
const text = fs.readFileSync(path.join(root, wf), "utf8")
// Match @shared/X or .../src/shared/X import specifiers and map to src/shared/X
const re = /(?:@shared\/|src\/shared\/)([A-Za-z0-9_./-]+)/g
let m
while ((m = re.exec(text))) {
const rel = m[1].replace(/\.(ts|tsx|js|jsx)$/, "")
for (const cand of [`src/shared/${rel}.ts`, `src/shared/${rel}.tsx`, `src/shared/${rel}/index.ts`]) {
if (fs.existsSync(path.join(root, cand))) sharedMentionedByWebview.add(cand)
}
}
}
for (const f of sharedMentionedByWebview) live.add(f)
console.log(`src/shared files referenced by webview: ${sharedMentionedByWebview.size}`)
// All non-test, non-.d.ts source files on disk.
const allSrc = (await glob("src/**/*.{ts,tsx}", { cwd: root }))
.filter((f) => !/\.test\.tsx?$/.test(f))
.filter((f) => !f.endsWith(".d.ts"))
.filter((f) => !f.includes("/__tests__/"))
.filter((f) => !f.startsWith("src/test/"))
.filter((f) => !f.startsWith("src/generated/")) // generated host glue
.filter((f) => !f.startsWith("src/dev/")) // dev-only tooling
const dead = allSrc.filter((f) => !live.has(f)).sort()
console.log(`extension inputs: ${ext.size}`)
console.log(`standalone inputs: ${standalone.size}`)
console.log(`union live src files: ${live.size}`)
console.log(`candidate dead files: ${dead.length}`)
fs.writeFileSync("/tmp/dead-src.json", JSON.stringify(dead, null, "\t"))
console.log("--- dead candidates written to /tmp/dead-src.json ---")
@@ -1,212 +0,0 @@
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { promisify } from "node:util";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const require = createRequire(import.meta.url);
const execFileAsync = promisify(execFile);
const repoRoot = path.resolve(__dirname, "../../..");
const sdkCatalogPath = path.join(
repoRoot,
"sdk/packages/llms/src/catalog/catalog.generated.ts",
);
const outputPath = path.join(
__dirname,
"../src/shared/models/models-dev-catalog.generated.ts",
);
const { GENERATED_PROVIDER_MODELS } = await import(
pathToFileURL(sdkCatalogPath).href
);
const providerLabels = Object.fromEntries([
["anthropic", "Anthropic"],
["bedrock", "Amazon Bedrock"],
["vertex", "GCP Vertex AI"],
["gemini", "Google Gemini"],
["openai-native", "OpenAI"],
["openai-codex", "ChatGPT Subscription"],
["deepseek", "DeepSeek"],
["xai", "xAI"],
["together", "Together"],
["sapaicore", "SAP AI Core"],
["fireworks", "Fireworks AI"],
["groq", "Groq"],
["cerebras", "Cerebras"],
["sambanova", "SambaNova"],
["nebius", "Nebius AI Studio"],
["huggingface", "Hugging Face"],
["openrouter", "OpenRouter"],
["vercel-ai-gateway", "Vercel AI Gateway"],
["aihubmix", "AIhubmix"],
["baseten", "Baseten"],
["zai", "Z AI"],
["lmstudio", "LM Studio"],
["requesty", "Requesty"],
["moonshot", "Moonshot"],
["minimax", "MiniMax"],
["wandb", "W&B Inference by CoreWeave"],
["mistral", "Mistral"],
["doubao", "Bytedance Doubao"],
["qwen", "Alibaba Qwen"],
["huawei-cloud-maas", "Huawei Cloud MaaS"],
["hicap", "Hicap"],
["nousResearch", "NousResearch"],
["openai", "OpenAI Compatible"],
["ollama", "Ollama"],
["litellm", "LiteLLM"],
["claude-code", "Claude Code"],
["qwen-code", "Qwen Code"],
["dify", "Dify.ai"],
["oca", "Oracle Code Assist"],
["vscode-lm", "GitHub Copilot"],
["cline", "Cline"],
["cline-pass", "ClinePass"],
["asksage", "AskSage"],
]);
const providerOrder = [
"cline",
"cline-pass",
"openai-codex",
"gemini",
"openai",
"anthropic",
"bedrock",
"vscode-lm",
"deepseek",
"openai-native",
"openrouter",
"ollama",
"vertex",
"litellm",
"claude-code",
"sapaicore",
"mistral",
"zai",
"groq",
"cerebras",
"vercel-ai-gateway",
"baseten",
"requesty",
"fireworks",
"together",
"qwen",
"qwen-code",
"doubao",
"lmstudio",
"moonshot",
"huggingface",
"nebius",
"asksage",
"xai",
"sambanova",
"huawei-cloud-maas",
"dify",
"oca",
"minimax",
"hicap",
"aihubmix",
"nousResearch",
"wandb",
];
function toLegacyModelInfo(model) {
const capabilities = new Set(model.capabilities ?? []);
const output = {
name: model.name,
maxTokens: model.maxTokens,
contextWindow: model.contextWindow ?? model.maxInputTokens,
supportsImages: capabilities.has("images"),
supportsPromptCache: capabilities.has("prompt-cache"),
supportsReasoning: capabilities.has("reasoning"),
inputPrice: model.pricing?.input ?? 0,
outputPrice: model.pricing?.output ?? 0,
cacheWritesPrice: model.pricing?.cacheWrite ?? 0,
cacheReadsPrice: model.pricing?.cacheRead ?? 0,
supportsTools: capabilities.has("tools"),
};
for (const key of Object.keys(output)) {
if (output[key] === undefined) {
delete output[key];
}
}
return output;
}
const providerModels = Object.fromEntries(
Object.entries(GENERATED_PROVIDER_MODELS.providers).map(
([providerId, models]) => [
providerId,
Object.fromEntries(
Object.entries(models).map(([modelId, model]) => [
modelId,
toLegacyModelInfo(model),
]),
),
],
),
);
const providerOptions = providerOrder
.filter((value) => providerLabels[value])
.map((value) => ({ value, label: providerLabels[value] }));
const file = `/**
* Auto-generated from @cline/llms models.dev catalog.
*
* Source: sdk/packages/llms/src/catalog/catalog.generated.ts
* Do not edit by hand; run apps/vscode/scripts/generate-models-dev-catalog.mjs after updating the SDK model catalog.
*/
import type { ApiProvider, ModelInfo, OpenAiCompatibleModelInfo } from "../api"
export const modelsDevProviderModels = ${JSON.stringify(providerModels, null, "\t")} as const satisfies Record<string, Record<string, ModelInfo | OpenAiCompatibleModelInfo>>
export const modelsDevProviderOptions = ${JSON.stringify(providerOptions, null, "\t")} as const satisfies ReadonlyArray<{ value: ApiProvider; label: string }>
export function getModelsDevProviderModels(provider: ApiProvider | string): Record<string, ModelInfo> {
\treturn (modelsDevProviderModels[provider as keyof typeof modelsDevProviderModels] ?? {}) as Record<string, ModelInfo>
}
export const modelsDevAnthropicModels = getModelsDevProviderModels("anthropic")
export const modelsDevBedrockModels = getModelsDevProviderModels("bedrock")
export const modelsDevCerebrasModels = getModelsDevProviderModels("cerebras")
export const modelsDevDeepSeekModels = getModelsDevProviderModels("deepseek")
export const modelsDevDoubaoModels = getModelsDevProviderModels("doubao")
export const modelsDevFireworksModels = getModelsDevProviderModels("fireworks")
export const modelsDevGeminiModels = getModelsDevProviderModels("gemini")
export const modelsDevGroqModels = getModelsDevProviderModels("groq")
export const modelsDevHuggingFaceModels = getModelsDevProviderModels("huggingface")
export const modelsDevMinimaxModels = getModelsDevProviderModels("minimax")
export const modelsDevMistralModels = getModelsDevProviderModels("mistral")
export const modelsDevMoonshotModels = getModelsDevProviderModels("moonshot")
export const modelsDevNebiusModels = getModelsDevProviderModels("nebius")
export const modelsDevNousResearchModels = getModelsDevProviderModels("nousResearch")
export const modelsDevOpenAiCodexModels = getModelsDevProviderModels("openai-codex")
export const modelsDevOpenAiNativeModels = getModelsDevProviderModels("openai-native")
export const modelsDevSambanovaModels = getModelsDevProviderModels("sambanova")
export const modelsDevSapAiCoreModels = getModelsDevProviderModels("sapaicore")
export const modelsDevVertexModels = getModelsDevProviderModels("vertex")
export const modelsDevWandbModels = getModelsDevProviderModels("wandb")
export const modelsDevXaiModels = getModelsDevProviderModels("xai")
`;
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, file);
await execFileAsync(
process.execPath,
[
require.resolve("@biomejs/biome/bin/biome"),
"format",
"--write",
outputPath,
],
{ cwd: path.resolve(__dirname, "..") },
);
+1 -1
View File
@@ -453,7 +453,7 @@ async function main() {
await fs.writeFile(STATE_PROTO_PATH, protoContent)
console.log(`Updated ${STATE_PROTO_PATH}`)
console.log("\nGeneration complete! Run 'npm run protos' to regenerate TypeScript from protos.")
console.log("\nGeneration complete! Run 'bun run protos' to regenerate TypeScript from protos.")
}
main().catch((error) => {
@@ -1,4 +1,4 @@
#!/usr/bin/env npx tsx
#!/usr/bin/env bun
/**
* Interactive Playwright launcher for the Cline VS Code extension.
@@ -15,10 +15,10 @@
*
* Usage:
* 1. (Optional) Build and install the e2e extension:
* npm run test:e2e:build
* bun run test:e2e:build
*
* 2. From the repo root, start the interactive session:
* npm run test:e2e:ui
* bun run test:e2e:ui
*
* 3. VS Code will launch with the Cline extension loaded and gRPC recording enabled.
*
+11 -3
View File
@@ -47,8 +47,13 @@ async function installNodeDependencies() {
await cpr(RUNTIME_DEPS_DIR, BUILD_DIR)
console.log("Running npm install in distribution directory...")
execSync("npm install", { stdio: "inherit", cwd: BUILD_DIR })
// This is an ISOLATED runtime install inside the standalone distribution
// directory (dist-standalone), driven by the "cline-core" runtime-files
// manifest — it is NOT part of the monorepo workspace install. TARGET_NODE_VERSION
// and the prebuild-install calls below target the Node ABI of the bundled
// runtime (matching the JetBrains-packaged Node), not the build tooling.
console.log("Running bun install in distribution directory...")
execSync("bun install", { stdio: "inherit", cwd: BUILD_DIR })
// Move the vscode directory into node_modules.
// It can't be installed using npm because it will create a symlink which cannot be unzipped correctly on windows.
@@ -92,7 +97,10 @@ async function packageAllBinaryDeps() {
const dest = path.join(binaryDir, module)
await cpr(src, dest)
// Download the binary libs
// Download the binary libs.
// `--target=${TARGET_NODE_VERSION}` selects the Node ABI of the bundled
// standalone runtime (NOT the bun/build tooling) so the prebuilt native
// `.node` binaries load in the Node that runs cline-core.
const v = IS_VERBOSE ? "--verbose" : ""
const cmd = `npx prebuild-install --platform=${platform} --arch=${arch} --target=${TARGET_NODE_VERSION} ${v}`
log_verbose(`${module}: ${cmd}`)
+5 -2
View File
@@ -37,13 +37,16 @@ process.on("SIGINT", cleanupOnSignal(130))
process.on("SIGTERM", cleanupOnSignal(143))
try {
const vsceArgs = ["publish", "--allow-package-secrets", "sendgrid"]
// --no-dependencies: the extension is fully esbuild-bundled into dist/extension.js,
// so vsce must not walk node_modules (the @cline/* workspace symlinks point out of
// the package and would drag the whole monorepo into the VSIX).
const vsceArgs = ["publish", "--no-dependencies", "--allow-package-secrets", "sendgrid"]
if (isPrerelease) {
vsceArgs.push("--pre-release")
}
execFileSync("vsce", vsceArgs, { stdio: "inherit" })
const ovsxArgs = ["ovsx", "publish"]
const ovsxArgs = ["ovsx", "publish", "--no-dependencies"]
if (isPrerelease) {
ovsxArgs.push("--pre-release")
}
+11 -8
View File
@@ -35,9 +35,9 @@
* least as often as the scheduled release nightly runs.
*
* Usage:
* npm run publish:marketplace:nightly # release channel
* npm run publish:marketplace:nightly -- --pre-release # pre-release channel
* npm run publish:marketplace:nightly -- --dry-run # package only
* bun run publish:marketplace:nightly # release channel
* bun run publish:marketplace:nightly -- --pre-release # pre-release channel
* bun run publish:marketplace:nightly -- --dry-run # package only
*
* Environment variables:
* VSCE_PAT - Personal Access Token for VS Code Marketplace
@@ -388,6 +388,9 @@ class NightlyPublisher {
const args = [
"package",
...(isPreRelease ? ["--pre-release"] : []),
// The extension is fully esbuild-bundled, so vsce must not walk node_modules
// (the @cline/* workspace symlinks point outside the package).
"--no-dependencies",
"--no-update-package-json",
"--no-git-tag-version",
"--allow-package-secrets",
@@ -579,7 +582,7 @@ if (showHelp) {
Nightly publish script for VS Code extension
Usage:
npm run publish:marketplace:nightly [options]
bun run publish:marketplace:nightly [options]
Options:
--pre-release Publish to the pre-release channel of cline-nightly.
@@ -593,10 +596,10 @@ Environment variables:
OVSX_PAT Personal Access Token for OpenVSX Registry
Examples:
npm run publish:marketplace:nightly # Release channel publish
npm run publish:marketplace:nightly -- --pre-release # Pre-release channel publish
npm run publish:marketplace:nightly -- --dry-run # Package only
VSCE_PAT="token" npm run publish:marketplace:nightly # Publish to VS Code only
bun run publish:marketplace:nightly # Release channel publish
bun run publish:marketplace:nightly -- --pre-release # Pre-release channel publish
bun run publish:marketplace:nightly -- --dry-run # Package only
VSCE_PAT="token" bun run publish:marketplace:nightly # Publish to VS Code only
`)
process.exit(0)
}
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bun
import { spawn } from "node:child_process"
import path from "node:path"
/**
* Runner for the SDK-adapter + model-catalog `bun test` suites (the same set
* `vitest.config.ts` covers; `test:vitest` runs them under vitest).
*
* Why a script instead of a bare `bun test <globs>`:
*
* 1. Curated include set. These suites are an explicit list (see
* INCLUDE_PATTERNS, kept in sync with `vitest.config.ts` `test.include`),
* not the whole tree, so the node-side unit and @vscode/test-cli suites are
* not pulled in. `bun test`'s positional args don't expand `**` the way we
* need, so we resolve the globs ourselves with bun's `Glob`.
*
* 2. One process per file. `bun test` runs all files in a single process by
* default, so `mock.module(...)` registrations leak between files — suites
* mocking the same specifier with different shapes (e.g.
* `@/core/storage/StateManager`, `@cline/core`) clobber each other.
* `--parallel` runs each file in its own worker process, giving each a fresh
* module registry.
*
* Usage:
* bun scripts/run-bun-tests.ts # run the curated set, isolated
* bun scripts/run-bun-tests.ts --list # print the resolved file list only
*/
import { Glob } from "bun"
// Mirror of vitest.config.ts `test.include`. Keep these in sync.
const INCLUDE_PATTERNS = [
"src/sdk/**/*.test.ts",
"src/shared/vsCodeSelectorUtils.test.ts",
"src/core/storage/remote-config/**/*.test.ts",
"src/shared/model-catalog/provider-helpers.test.ts",
"src/core/controller/models/__tests__/providerCatalogHandlers.test.ts",
"src/core/controller/models/__tests__/providerSwitchNormalization.test.ts",
"src/core/controller/models/__tests__/resolveModelInfo.test.ts",
"src/core/controller/models/__tests__/providerCatalogSmoke.test.ts",
"src/core/controller/models/__tests__/refreshClineRecommendedModels.test.ts",
]
const projectRoot = path.resolve(import.meta.dir, "..")
async function resolveFiles(): Promise<string[]> {
const seen = new Set<string>()
for (const pattern of INCLUDE_PATTERNS) {
const glob = new Glob(pattern)
for await (const match of glob.scan({ cwd: projectRoot, onlyFiles: true })) {
seen.add(match)
}
}
return [...seen].sort()
}
async function main(): Promise<void> {
const files = await resolveFiles()
if (files.length === 0) {
console.error("run-bun-tests: no test files matched the include patterns")
process.exit(1)
}
const passthrough = process.argv.slice(2)
if (passthrough.includes("--list")) {
console.log(files.join("\n"))
return
}
const args = ["test", "--parallel", ...passthrough.filter((arg) => arg !== "--list"), ...files]
const child = spawn("bun", args, { cwd: projectRoot, stdio: "inherit" })
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal)
return
}
process.exit(code ?? 1)
})
}
void main()
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env bun
import path from "node:path"
/**
* Runner for the node-side `bun test` unit suites.
*
* A test file belongs to this runner iff it imports from "bun:test" (files that
* need the real VS Code extension host import from "mocha" and run under
* @vscode/test-cli instead). We glob all `*.test.ts` and keep only the
* bun:test ones; the SDK/model-catalog suites listed in IGNORED run through
* `run-bun-tests.ts`, so they're skipped here to avoid double-running.
*
* Why one process per file: `bun test --parallel <allFiles>` reuses a pool of
* worker processes, and `mock.module(...)` registrations accumulate across files
* sharing a worker. Suites that mock the same specifier with different shapes
* (e.g. `@core/storage/disk`, `@cline/core`, `fs/promises`, `os`) then clobber
* each other and fail only at scale. Spawning one `bun test` process per file
* (bounded by a small concurrency pool, via `Bun.spawn`) gives each file a fresh
* module registry.
*
* Usage:
* bun scripts/run-bun-unit-tests.ts # run the suite, isolated
* bun scripts/run-bun-unit-tests.ts --list # print the resolved file list
* bun scripts/run-bun-unit-tests.ts --all # include ELECTRON_HOST_ONLY
* bun scripts/run-bun-unit-tests.ts -c 6 # concurrency (default 4)
*/
import { Glob } from "bun"
const projectRoot = path.resolve(import.meta.dir, "..")
// A file runs under `bun test` iff it imports "bun:test"; mocha-owned files are
// skipped by the import filter in resolveFiles().
const INCLUDE_PATTERNS = ["src/**/*.test.ts"]
const BUN_TEST_IMPORT = /from\s+["']bun:test["']/
// SDK + model-catalog suites run through `run-bun-tests.ts`; skip them here so
// they aren't run twice.
const IGNORED = new Set<string>([
"src/core/controller/models/__tests__/providerCatalogHandlers.test.ts",
"src/core/controller/models/__tests__/providerCatalogSmoke.test.ts",
"src/core/controller/models/__tests__/providerSwitchNormalization.test.ts",
"src/core/controller/models/__tests__/resolveModelInfo.test.ts",
"src/core/controller/models/__tests__/refreshClineRecommendedModels.test.ts",
])
// Files that require the real VSCode Electron host (@vscode/test-cli). Excluded
// by default; they continue to run under @vscode/test-cli.
const ELECTRON_HOST_ONLY = new Set<string>([])
async function resolveFiles(includeHostOnly: boolean): Promise<string[]> {
const seen = new Set<string>()
for (const pattern of INCLUDE_PATTERNS) {
const glob = new Glob(pattern)
for await (const match of glob.scan({ cwd: projectRoot, onlyFiles: true })) {
const normalized = match.split(path.sep).join("/")
if (IGNORED.has(normalized)) {
continue
}
if (!includeHostOnly && ELECTRON_HOST_ONLY.has(normalized)) {
continue
}
// Only bun-runner-owned files (those importing "bun:test"). Files still on
// the @vscode/test-cli Electron host import from "mocha" and are skipped.
const source = await Bun.file(path.join(projectRoot, normalized)).text()
if (!BUN_TEST_IMPORT.test(source)) {
continue
}
seen.add(normalized)
}
}
return [...seen].sort()
}
type FileResult = {
file: string
code: number
pass: number
fail: number
output: string
}
// `bun test` prints its summary as e.g. " 12 pass\n 0 fail".
function parseCounts(output: string): { pass: number; fail: number } {
let pass = 0
let fail = 0
for (const m of output.matchAll(/^\s*(\d+)\s+pass\b/gm)) {
pass += Number(m[1])
}
for (const m of output.matchAll(/^\s*(\d+)\s+fail\b/gm)) {
fail += Number(m[1])
}
return { pass, fail }
}
const PER_FILE_TIMEOUT_MS = 120_000
async function runOne(file: string): Promise<FileResult> {
const proc = Bun.spawn(["bun", "test", file], {
cwd: projectRoot,
stdout: "pipe",
stderr: "pipe",
env: { ...process.env, FORCE_COLOR: "0" },
})
// Guard against a single hung file stalling the whole pool: kill it after a
// generous per-file budget and surface it as a failure.
let timedOut = false
const timer = setTimeout(() => {
timedOut = true
proc.kill()
}, PER_FILE_TIMEOUT_MS)
const [stdout, stderr, code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
])
clearTimeout(timer)
const output = stdout + stderr + (timedOut ? `\n[runner] TIMEOUT after ${PER_FILE_TIMEOUT_MS}ms — killed\n` : "")
const { pass, fail } = parseCounts(output)
// A nonzero exit with no parsed counts (load/parse error, timeout) must count
// as a failure so the gate cannot pass silently.
const effectiveFail = timedOut && fail === 0 ? Math.max(fail, 1) : fail
return { file, code, pass, fail: effectiveFail, output }
}
async function runPool(files: string[], concurrency: number): Promise<FileResult[]> {
const results: FileResult[] = []
let next = 0
const launch = async (): Promise<void> => {
while (next < files.length) {
const file = files[next++]
const result = await runOne(file)
results.push(result)
const failed = result.fail > 0 || result.code !== 0
const status = failed ? "FAIL" : "ok"
const counts = `${result.pass} pass / ${result.fail} fail`
process.stdout.write(`[${results.length}/${files.length}] ${status.padEnd(4)} ${counts.padEnd(20)} ${file}\n`)
if (failed) {
process.stdout.write(result.output.trimEnd() + "\n")
}
}
}
const workers: Promise<void>[] = []
for (let i = 0; i < Math.min(concurrency, files.length); i++) {
workers.push(launch())
}
await Promise.all(workers)
return results
}
function parseConcurrency(argv: string[]): number {
const flagIdx = argv.findIndex((a) => a === "-c" || a === "--concurrency")
if (flagIdx !== -1 && argv[flagIdx + 1]) {
const n = Number(argv[flagIdx + 1])
if (Number.isFinite(n) && n > 0) {
return Math.floor(n)
}
}
return 4
}
async function main(): Promise<void> {
const passthrough = process.argv.slice(2)
const includeHostOnly = passthrough.includes("--all")
const files = await resolveFiles(includeHostOnly)
if (files.length === 0) {
console.error("run-bun-unit-tests: no test files matched")
process.exit(1)
}
if (passthrough.includes("--list")) {
console.log(files.join("\n"))
return
}
const concurrency = parseConcurrency(passthrough)
const started = Date.now()
console.log(`Running ${files.length} unit test files, isolated (concurrency ${concurrency})…\n`)
const results = await runPool(files, concurrency)
const totalPass = results.reduce((sum, r) => sum + r.pass, 0)
const totalFail = results.reduce((sum, r) => sum + r.fail, 0)
const failedFiles = results.filter((r) => r.fail > 0 || r.code !== 0).sort((a, b) => a.file.localeCompare(b.file))
const elapsed = ((Date.now() - started) / 1000).toFixed(1)
console.log("\n──────────────────────────────────────────────")
console.log(`Files: ${results.length} Pass: ${totalPass} Fail: ${totalFail} Time: ${elapsed}s`)
if (failedFiles.length > 0) {
console.log(`\nFailing files (${failedFiles.length}):`)
for (const r of failedFiles) {
console.log(` ${r.file} (${r.pass} pass / ${r.fail} fail, exit ${r.code})`)
}
process.exit(1)
}
console.log("All unit test files passed.")
}
void main()
+5 -5
View File
@@ -18,11 +18,11 @@ fi
# Step 1: Build protos (everything depends on this)
echo "Building protos..."
npm run protos || { echo "Protos build failed"; exit 1; }
bun run protos || { echo "Protos build failed"; exit 1; }
# Step 2: Build webview once
echo "Building webview..."
npm run build:webview || { echo "Webview build failed"; exit 1; }
bun run build:webview || { echo "Webview build failed"; exit 1; }
# Step 3: Kill existing session if one is running
tmux kill-session -t "$SESSION" 2>/dev/null
@@ -44,9 +44,9 @@ tmux select-layout -t "$SESSION" even-horizontal
# Ctrl+C kills the whole session
tmux bind-key -T root C-c kill-session
tmux send-keys -t "$SESSION:0.0" "npm run watch:esbuild" Enter
tmux send-keys -t "$SESSION:0.1" "npm run watch:tsc" Enter
tmux send-keys -t "$SESSION:0.2" "npm run dev:webview" Enter
tmux send-keys -t "$SESSION:0.0" "bun run watch:esbuild" Enter
tmux send-keys -t "$SESSION:0.1" "bun run watch:tsc" Enter
tmux send-keys -t "$SESSION:0.2" "bun run dev:webview" Enter
tmux send-keys -t "$SESSION:0.3" "while [ ! -f '$WORKSPACE/dist/extension.js' ]; do sleep 0.5; done && echo 'Launching Extension Host...' && code --extensionDevelopmentPath='$WORKSPACE' --disable-workspace-trust --disable-extension saoudrizwan.claude-dev --disable-extension saoudrizwan.cline-nightly '$WORKSPACE' && echo 'Extension Host launched.'" Enter
# Attach to the session
+42 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env npx tsx
#!/usr/bin/env bun
import * as grpc from "@grpc/grpc-js"
import { ReflectionService } from "@grpc/reflection"
import * as health from "grpc-health-check"
@@ -87,6 +87,12 @@ function createMockService<T extends grpc.UntypedServiceImplementation>(serviceN
})
return
case "openExternal":
simulateOAuthBrowserCallback(call.request?.value || "")
.then(() => callback(null, {}))
.catch((error) => callback(error))
return
case "getWebviewHtml":
callback(null, {
html: "<html><body>Fake Webview</body></html>",
@@ -143,6 +149,41 @@ function createMockService<T extends grpc.UntypedServiceImplementation>(serviceN
return new Proxy({} as T, handler)
}
async function simulateOAuthBrowserCallback(urlString: string): Promise<void> {
let url: URL
try {
url = new URL(urlString)
} catch {
return
}
if (!isLoopbackHost(url.hostname) || url.pathname !== "/api/v1/auth/authorize") {
return
}
const callbackUrl = url.searchParams.get("callback_url") ?? url.searchParams.get("redirect_uri")
if (!callbackUrl) {
return
}
const callback = new URL(callbackUrl)
if (!isLoopbackHost(callback.hostname) || callback.pathname !== "/auth") {
return
}
callback.searchParams.set("code", "test-personal-token")
callback.searchParams.set("provider", "cline")
const response = await fetch(callback.toString())
if (!response.ok) {
throw new Error(`Mock OAuth callback failed: ${response.status} ${response.statusText}`)
}
}
function isLoopbackHost(hostname: string): boolean {
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"
}
if (require.main === module) {
startTestHostBridgeServer().catch((err) => {
console.error("Failed to start test host bridge server:", err)
@@ -1,4 +1,4 @@
#!/usr/bin/env npx tsx
#!/usr/bin/env bun
/**
* Simple Cline gRPC Server
@@ -7,13 +7,13 @@
* without requiring the full installation, while automatically mocking all external services. Simply run:
*
* # One-time setup (generates protobuf files)
* npm run compile-standalone
* npm run test:sca-server
* bun run compile-standalone
* bun run test:sca-server
*
* The following components are started automatically:
* 1. HostBridge test server
* 2. ClineApiServerMock (mock implementation of the Cline API)
* 3. AuthServiceMock (activated if E2E_TEST="true")
* 3. SDK WorkOS device-auth flow, with WorkOS fetches mocked by testing-platform-workos-fetch-mock.cjs
*
* Environment Variables for Customization:
* PROJECT_ROOT - Override project root directory (default: parent of scripts dir)
@@ -22,7 +22,7 @@
* PROTOBUS_PORT - gRPC server port (default: 26040)
* HOSTBRIDGE_PORT - HostBridge server port (default: 26041)
* WORKSPACE_DIR - Working directory (default: current directory)
* E2E_TEST - Enable E2E test mode (default: true)
* E2E_TEST - Enable legacy mock auth mode (default: false)
* CLINE_ENVIRONMENT - Environment setting (default: local)
*
* Ideal for local development, testing, or lightweight E2E scenarios.
@@ -38,7 +38,7 @@ import { ClineApiServerMock } from "../src/test/e2e/fixtures/server/index"
const PROTOBUS_PORT = process.env.PROTOBUS_PORT || "26040"
const HOSTBRIDGE_PORT = process.env.HOSTBRIDGE_PORT || "26041"
const WORKSPACE_DIR = process.env.WORKSPACE_DIR || process.cwd()
const E2E_TEST = process.env.E2E_TEST || "true"
const E2E_TEST = process.env.E2E_TEST || "false"
const CLINE_ENVIRONMENT = process.env.CLINE_ENVIRONMENT || "local"
const USE_C8 = process.env.USE_C8 === "true"
@@ -66,7 +66,7 @@ async function main(): Promise<void> {
console.error(" CLINE_DIST_DIR - Override distribution directory")
console.error(" CLINE_CORE_FILE - Override core file name")
console.error("")
console.error("To build the standalone version, run: npm run compile-standalone")
console.error("To build the standalone version, run: bun run compile-standalone")
process.exit(1)
}
@@ -83,8 +83,8 @@ async function main(): Promise<void> {
const clineTestWorkspace = mkdtempSync(path.join(os.tmpdir(), "cline-test-workspace-"))
console.log("Starting HostBridge test server...")
const hostbridge: ChildProcess = spawn("npx", ["tsx", path.join(__dirname, "test-hostbridge-server.ts")], {
stdio: "pipe",
const hostbridge: ChildProcess = spawn("bun", [path.join(__dirname, "test-hostbridge-server.ts")], {
stdio: "inherit",
env: {
...process.env,
TEST_HOSTBRIDGE_WORKSPACE_DIR: clineTestWorkspace,
@@ -115,13 +115,16 @@ async function main(): Promise<void> {
const covDir = path.join(projectRoot, `coverage/coverage-core-${PROTOBUS_PORT}`)
const baseArgs = ["--enable-source-maps", path.join(distDir, "cline-core.js")]
const workosFetchMockPath = path.join(projectRoot, "scripts", "testing-platform-workos-fetch-mock.cjs")
const baseArgs = ["--enable-source-maps", "--require", workosFetchMockPath, path.join(distDir, "cline-core.js")]
const spawnArgs = USE_C8 ? ["c8", "--report-dir", covDir, "node", ...baseArgs] : ["node", ...baseArgs]
const c8Bin = path.join(projectRoot, "node_modules", ".bin", process.platform === "win32" ? "c8.cmd" : "c8")
const spawnCommand = USE_C8 ? c8Bin : "node"
const spawnArgs = USE_C8 ? ["--report-dir", covDir, "node", ...baseArgs] : baseArgs
console.log(`Starting Cline Core Service... (useC8=${USE_C8})`)
const coreService: ChildProcess = spawn("npx", spawnArgs, {
const coreService: ChildProcess = spawn(spawnCommand, spawnArgs, {
cwd: projectRoot,
env: {
...process.env,
@@ -1,15 +1,15 @@
#!/usr/bin/env npx tsx
#!/usr/bin/env bun
/**
* Test Orchestrator
*
* Automates server lifecycle for running spec files against the standalone server.
*
* Prerequisites:
* Build standalone first: `npm run compile-standalone`
* Build standalone first: `bun run compile-standalone`
*
* Usage:
* - Single file: `npm run test:tp-orchestrator path/to/spec.json`
* - All specs dir: `npm run test:tp-orchestrator tests/specs`
* - Single file: `bun run test:tp-orchestrator path/to/spec.json`
* - All specs dir: `bun run test:tp-orchestrator tests/specs`
*
* Flags:
* --server-logs Show server logs (hidden by default)
@@ -29,7 +29,7 @@ import kill from "tree-kill"
let showServerLogs = false
let fix = false
let coverage = false
const WAIT_SERVER_DEFAULT_TIMEOUT = 15000
const WAIT_SERVER_DEFAULT_TIMEOUT = 60000
const usedPorts = new Set<number>()
/**
@@ -94,8 +94,10 @@ async function startServer(): Promise<{ server: ChildProcess; grpcPort: string }
const grpcPort = (await getAvailablePort()).toString()
const hostbridgePort = (await getAvailablePort()).toString()
const server = spawn("npx", ["tsx", "scripts/test-standalone-core-api-server.ts"], {
stdio: showServerLogs ? "inherit" : "pipe",
const server = spawn("bun", ["scripts/test-standalone-core-api-server.ts"], {
// When logs are hidden, ignore stdio instead of piping it without a reader:
// an unread pipe can fill and stall server startup/shutdown in CI.
stdio: showServerLogs ? "inherit" : "ignore",
env: {
...process.env,
PROTOBUS_PORT: grpcPort,
@@ -104,29 +106,50 @@ async function startServer(): Promise<{ server: ChildProcess; grpcPort: string }
},
})
// Wait for either the server to become ready or fail on spawn error
await Promise.race([
waitForPort(Number(grpcPort), "127.0.0.1", WAIT_SERVER_DEFAULT_TIMEOUT),
new Promise((_, reject) => server.once("error", reject)),
])
try {
// Wait for either the server to become ready or fail on spawn error.
await Promise.race([
waitForPort(Number(grpcPort), "127.0.0.1", WAIT_SERVER_DEFAULT_TIMEOUT),
new Promise((_, reject) => server.once("error", reject)),
new Promise((_, reject) =>
server.once("exit", (code, signal) => reject(new Error(`Server exited before ready: ${code ?? signal}`))),
),
])
} catch (error) {
await stopServer(server)
throw error
}
return { server, grpcPort }
}
function stopServer(server: ChildProcess): Promise<void> {
return new Promise((resolve) => {
if (!server.pid) return resolve()
if (!server.pid || server.exitCode !== null || server.signalCode !== null) return resolve()
let settled = false
const finish = () => {
if (!settled) {
settled = true
resolve()
}
}
server.once("exit", finish)
kill(server.pid, "SIGINT", (err) => {
if (err) console.warn("Failed to kill server process:", err)
server.once("exit", () => resolve())
})
setTimeout(() => {
if (!settled && server.pid) {
kill(server.pid, "SIGKILL", finish)
}
}, 5000).unref()
})
}
function runTestingPlatform(specFile: string, grpcPort: string): Promise<void> {
return new Promise((resolve, reject) => {
const testProcess = spawn("npx", ["ts-node", "index.ts", specFile, ...(fix ? ["--fix"] : [])], {
const testProcess = spawn("bun", ["index.ts", specFile, ...(fix ? ["--fix"] : [])], {
cwd: path.join(process.cwd(), "testing-platform"),
stdio: "inherit",
env: {
@@ -209,7 +232,7 @@ async function main() {
if (!inputPath) {
console.error(
"Usage: npx tsx scripts/testing-platform-orchestrator.ts <spec-file-or-folder> [--count=N] [--server-logs] [--fix] [--coverage]",
"Usage: bun scripts/testing-platform-orchestrator.ts <spec-file-or-folder> [--count=N] [--server-logs] [--fix] [--coverage]",
)
process.exit(1)
}
@@ -0,0 +1,56 @@
// Preload used by the standalone testing platform.
// It makes the SDK WorkOS device-auth flow deterministic and fully local while
// leaving production auth code on the same device-auth path used by users.
const originalFetch = globalThis.fetch?.bind(globalThis)
const WORKOS_ORIGIN = "https://api.workos.com"
const DEVICE_CODE = "test-device-code"
const USER_CODE = "PTBC-TXTP"
const ACCESS_TOKEN = "test-personal-token"
const REFRESH_TOKEN = "test-personal-token_refresh"
function jsonResponse(body, init = {}) {
return new Response(JSON.stringify(body), {
status: init.status ?? 200,
headers: { "Content-Type": "application/json", ...(init.headers ?? {}) },
})
}
function inputUrl(input) {
if (typeof input === "string") return input
if (input instanceof URL) return input.toString()
if (input && typeof input === "object" && "url" in input) return input.url
return String(input)
}
globalThis.fetch = async (input, init) => {
const urlString = inputUrl(input)
let url
try {
url = new URL(urlString)
} catch {
return originalFetch(input, init)
}
if (url.origin === WORKOS_ORIGIN && url.pathname === "/user_management/authorize/device") {
return jsonResponse({
device_code: DEVICE_CODE,
user_code: USER_CODE,
verification_uri: "https://login.workos.test/device",
verification_uri_complete: `https://login.workos.test/device?user_code=${USER_CODE}`,
expires_in: 300,
interval: 1,
})
}
if (url.origin === WORKOS_ORIGIN && url.pathname === "/user_management/authenticate") {
return jsonResponse({
access_token: ACCESS_TOKEN,
refresh_token: REFRESH_TOKEN,
token_type: "Bearer",
})
}
return originalFetch(input, init)
}
+17 -6
View File
@@ -1,9 +1,21 @@
import { afterEach, beforeEach, describe, it } from "mocha"
import { afterEach, beforeEach, describe, it, mock } from "bun:test"
import "should"
import fs from "fs/promises"
import os from "os"
import * as actualOs from "os"
import path from "path"
import sinon from "sinon"
// The SUT does `import * as os from "os"; os.homedir()`. Under bun, sinon's
// `stub(os, "homedir")` on the test's own `os` binding does NOT propagate to the
// SUT's namespace import, so inject a module-level homedir stub via mock.module
// (the rest of `os` — tmpdir() etc. — keeps its real behavior).
const homedirStub = sinon.stub()
const osMockNamespace = { ...actualOs, homedir: homedirStub }
const osMock = () => ({ ...osMockNamespace, default: osMockNamespace })
mock.module("os", osMock)
mock.module("node:os", osMock)
import os from "os"
import { ClineConfigurationError, ClineEndpoint, ClineEnv, Environment } from "../config"
describe("ClineEndpoint configuration", () => {
@@ -19,11 +31,10 @@ describe("ClineEndpoint configuration", () => {
// Create .cline directory
await fs.mkdir(path.join(tempDir, ".cline"), { recursive: true })
// Stub os.homedir to return our temp directory
// Stub os.homedir to return our temp directory (via mock.module homedirStub)
originalHomedir = os.homedir
sandbox
.stub(os, "homedir")
.returns(tempDir)
homedirStub.reset()
homedirStub.returns(tempDir)
// Reset the singleton state using internal method
;(ClineEndpoint as any)._instance = null
+7 -4
View File
@@ -4,13 +4,13 @@ import "./utils/path" // necessary to have access to String.prototype.toPosix
import { HostProvider } from "@/hosts/host-provider"
import { Logger } from "@/shared/services/Logger"
import type { StorageContext } from "@/shared/storage/storage-context"
import { FileContextTracker } from "./core/context/context-tracking/FileContextTracker"
import { clearOnboardingModelsCache } from "./core/controller/models/getClineOnboardingModels"
import { HookDiscoveryCache } from "./core/hooks/HookDiscoveryCache"
import { HookProcessRegistry } from "./core/hooks/HookProcessRegistry"
import { StateManager } from "./core/storage/StateManager"
import { AgentConfigLoader } from "./core/task/tools/subagent/AgentConfigLoader"
import { ExtensionRegistryInfo } from "./registry"
import { registerVsCodeLmHandler } from "./sdk/vscode-lm/register-vscode-lm"
import { ErrorService } from "./services/error"
import { featureFlagsService } from "./services/feature-flags"
import { getDistinctId } from "./services/logging/distinctId"
@@ -52,6 +52,11 @@ export async function initialize(storageContext: StorageContext): Promise<Webvie
})
}
// Register host-only SDK provider handlers (e.g. VS Code Language Model API),
// which depend on the `vscode` module and cannot live in the SDK package.
// Must run before any handler is built (standalone utilities or task loop).
registerVsCodeLmHandler()
// =============== External services ===============
await ErrorService.initialize()
// Initialize PostHog client provider (skip in self-hosted mode)
@@ -74,8 +79,6 @@ export async function initialize(storageContext: StorageContext): Promise<Webvie
syncWorker().init({ ...blobStoreSettings, userDistinctId: getDistinctId() })
// Clean up old temp files in background (non-blocking) and start periodic cleanup every 24 hours
ClineTempManager.startPeriodicCleanup()
// Clean up orphaned file context warnings (startup cleanup)
FileContextTracker.cleanupOrphanedWarnings(stateManager)
telemetryService.captureExtensionActivated()
@@ -106,7 +109,7 @@ async function showVersionUpdateAnnouncement(stateManager: StateManager) {
})
}
// Always update the main version tracker for the next launch.
await stateManager.setGlobalState("clineVersion", currentVersion)
stateManager.setGlobalState("clineVersion", currentVersion)
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
+3 -4
View File
@@ -4,12 +4,11 @@ import * as path from "path"
import { Environment, type EnvironmentConfig } from "./shared/config-types"
import { Logger } from "./shared/services/Logger"
export { Environment, type EnvironmentConfig }
/**
export { Environment } /**
* Schema for the endpoints.json configuration file used in on-premise deployments.
* All fields are required and must be valid URLs.
*/
interface EndpointsFileSchema {
appBaseUrl: string
apiBaseUrl: string
@@ -36,7 +35,7 @@ class ClineEndpoint {
private onPremiseConfig: EndpointsFileSchema | null = null
private environment: Environment = Environment.production
// Track if config came from bundled file (enterprise distribution)
private isBundled: boolean = false
private isBundled = false
private constructor() {
// Set environment at module load. Use override if provided.
File diff suppressed because it is too large Load Diff
@@ -1,606 +0,0 @@
import { ClineStorageMessage } from "@/shared/messages/content"
const APPLY_PATCH_PATCH_REGEX = /\*\*\* Begin Patch\s+([\s\S]*?)\s+\*\*\* End Patch/m
/**
* Convert apply_patch tool calls to write_to_file and replace_in_file format
*/
export function convertApplyPatchToolCalls(messages: Array<ClineStorageMessage>): Array<ClineStorageMessage> {
// Map to track tool_use_id to converted tool info and original input
const toolUseIdMap = new Map<string, { name: string; input: any; originalInput: any }>()
return messages.map((message) => {
if (!Array.isArray(message.content)) {
return message
}
const convertedContent = message.content.map((block) => {
// Handle tool_use blocks
if (block.type === "tool_use" && block.name === "apply_patch") {
const converted = convertApplyPatchToToolCalls(block.input)
// Store the conversion with original input for matching tool_result
toolUseIdMap.set(block.id, { ...converted, originalInput: block.input })
return {
...block,
name: converted.name,
input: converted.input,
}
}
// Handle tool_result blocks
if (block.type === "tool_result") {
const conversion = toolUseIdMap.get(block.tool_use_id)
if (conversion) {
// Reconstruct the tool_result content to match apply_patch format
const reconstructedContent = reconstructApplyPatchResult(
block,
conversion.name,
conversion.input,
conversion.originalInput,
)
return {
...block,
content: reconstructedContent,
}
}
}
return block
})
return {
...message,
content: convertedContent,
}
})
}
interface ConvertedTool {
name: string
input: any
}
/**
* Parse apply_patch input and convert to write_to_file or replace_in_file format
*/
function convertApplyPatchToToolCalls(input: any): ConvertedTool {
const patchInput = typeof input === "string" ? input : input?.input || ""
// Parse the patch format
const patchMatch = patchInput.match(APPLY_PATCH_PATCH_REGEX)
if (!patchMatch) {
// If we can't parse it, return as-is with write_to_file
return {
name: "write_to_file",
input: input,
}
}
const patchContent = patchMatch[1]
// Extract file operation (Add, Update, or Delete)
const fileMatch = patchContent.match(/\*\*\* (Add|Update|Delete) File: (.+?)(?:\n|$)/m)
if (!fileMatch) {
return {
name: "write_to_file",
input: input,
}
}
const action = fileMatch[1]
const filePath = fileMatch[2].trim()
// If it's an Add operation, convert to write_to_file
if (action === "Add") {
// Extract the content after the file line
const contentAfterFile = patchContent.substring(fileMatch.index! + fileMatch[0].length)
return {
name: "write_to_file",
input: {
absolutePath: filePath,
content: extractNewContentFromPatch(contentAfterFile),
},
}
}
// If it's Update or Delete, convert to replace_in_file
if (action === "Update" || action === "Delete") {
const diff = convertPatchToDiff(patchContent.substring(fileMatch.index! + fileMatch[0].length))
return {
name: "replace_in_file",
input: {
absolutePath: filePath,
diff: diff,
},
}
}
// Fallback
return {
name: "write_to_file",
input: input,
}
}
/**
* Extract new content from add operation patch
*/
function extractNewContentFromPatch(patchContent: string): string {
// For Add operations, the patch should contain lines starting with +
const lines = patchContent.split("\n")
const contentLines: string[] = []
for (const line of lines) {
if (line.startsWith("+")) {
// Remove the + prefix and exactly ONE space if present (but not if it's a tab)
let content = line.substring(1)
if (content.startsWith(" ") && !content.startsWith("\t")) {
content = content.substring(1)
}
contentLines.push(content)
}
}
return contentLines.join("\n")
}
/**
* Convert V4A patch format to SEARCH/REPLACE format
*/
function convertPatchToDiff(patchContent: string): string {
const diffBlocks: string[] = []
const lines = patchContent.split("\n")
let i = 0
while (i < lines.length) {
const line = lines[i]
// Skip empty lines at the start
if (!line.trim() && i === 0) {
i++
continue
}
// Check if this is the start of a hunk (@@) or a direct change line
if (line.trim().startsWith("@@") || line.startsWith("-") || line.startsWith("+")) {
const currentSearch: string[] = []
const currentReplace: string[] = []
// Collect @@ context marker lines
// @@ prefix marks context lines. If @@something, then "something" is context.
// If just @@, then it's an empty context line.
while (i < lines.length && lines[i].trim().startsWith("@@")) {
const trimmedLine = lines[i].trim()
// Extract the actual context content after @@
const contextLine = trimmedLine.substring(2)
// Always add the context line (even if empty)
currentSearch.push(contextLine)
currentReplace.push(contextLine)
i++
}
if (i >= lines.length) {
break
}
// Collect all remaining lines in this hunk until we hit end of content or next @@
const hunkLines: string[] = []
while (i < lines.length) {
// Check if this is a new hunk (starts with @@)
if (lines[i].trim().startsWith("@@")) {
break
}
hunkLines.push(lines[i])
i++
}
// Now process the hunk to build SEARCH/REPLACE
let hasChanges = false
for (let j = 0; j < hunkLines.length; j++) {
const hunkLine = hunkLines[j]
if (hunkLine.startsWith("-")) {
hasChanges = true
// Strip the - prefix and exactly ONE space if present (but not if it's a tab)
let content = hunkLine.substring(1)
if (content.startsWith(" ") && !content.startsWith(" \t")) {
content = content.substring(1)
}
currentSearch.push(content)
} else if (hunkLine.startsWith("+")) {
hasChanges = true
// Strip the + prefix and exactly ONE space if present (but not if it's a tab)
let content = hunkLine.substring(1)
if (content.startsWith(" ") && !content.startsWith(" \t")) {
content = content.substring(1)
}
currentReplace.push(content)
} else {
// Context line without @@ prefix - add to both sides
currentSearch.push(hunkLine)
currentReplace.push(hunkLine)
}
}
// Create the diff block if we have changes
if (hasChanges && (currentSearch.length > 0 || currentReplace.length > 0)) {
diffBlocks.push(
"------- SEARCH\n" +
currentSearch.join("\n") +
"\n=======\n" +
currentReplace.join("\n") +
"\n+++++++ REPLACE",
)
}
} else {
i++
}
}
return diffBlocks.join("\n")
}
/**
* Reconstruct tool_result content to match apply_patch format by extracting
* the final file content and converting it back to V4A patch format
*/
function reconstructApplyPatchResult(
block: any,
convertedToolName: string,
_convertedInput: any,
originalInput: any,
): string | any[] {
// Extract the content from the tool_result
const content = typeof block.content === "string" ? block.content : ""
// Try to extract the final_file_content
const finalContentMatch = content.match(/<final_file_content path="([^"]+)">\s*([\s\S]*?)\s*<\/final_file_content>/)
if (!finalContentMatch) {
// If no final_file_content found, return original content
return block.content
}
const filePath = finalContentMatch[1]
const finalContent = finalContentMatch[2]
// Reconstruct the result message based on the converted tool type
if (convertedToolName === "write_to_file") {
// For write_to_file, we just need to confirm the file was created/written
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully saved to ${filePath}.\n\nThe file has been created/updated with the new content.`
}
if (convertedToolName === "replace_in_file") {
// For replace_in_file, we need to reconstruct the V4A patch format result
// Try to parse the original patch to get the action and build context
const patchInput = typeof originalInput === "string" ? originalInput : originalInput?.input || ""
const patchMatch = patchInput.match(APPLY_PATCH_PATCH_REGEX)
if (patchMatch) {
const patchContent = patchMatch[1]
const fileMatch = patchContent.match(/\*\*\* (Add|Update|Delete) File: (.+?)(?:\n|$)/m)
if (fileMatch) {
const action = fileMatch[1]
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully updated in ${filePath}.\n\nThe file has been modified using ${action} operation.\n\n<final_file_content path="${filePath}">\n${finalContent}\n</final_file_content>\n\nIMPORTANT: For any future changes to this file, use the final_file_content shown above as your reference.`
}
}
// Fallback for replace_in_file
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully updated in ${filePath}.\n\n<final_file_content path="${filePath}">\n${finalContent}\n</final_file_content>\n\nIMPORTANT: For any future changes to this file, use the final_file_content shown above as your reference.`
}
// Default fallback
return block.content
}
/**
* Convert write_to_file and replace_in_file tool calls to apply_patch format
*/
export function convertWriteToFileToolCalls(messages: Array<ClineStorageMessage>): Array<ClineStorageMessage> {
// Map to track tool_use_id to converted tool info and original input
const toolUseIdMap = new Map<string, { originalName: string; originalInput: any; patchInput?: string }>()
// First pass: collect tool_use blocks
for (const message of messages) {
if (!Array.isArray(message.content)) {
continue
}
for (const block of message.content) {
if (block.type === "tool_use" && (block.name === "write_to_file" || block.name === "replace_in_file")) {
toolUseIdMap.set(block.id, {
originalName: block.name,
originalInput: block.input,
})
}
}
}
// Second pass: find tool_results and extract final content to build proper patches
const finalContentMap = new Map<string, string>()
for (const message of messages) {
if (!Array.isArray(message.content)) {
continue
}
for (const block of message.content) {
if (block.type === "tool_result" && toolUseIdMap.has(block.tool_use_id)) {
const content = typeof block.content === "string" ? block.content : ""
const finalContentMatch = content.match(
/<final_file_content path="([^"]+)">\s*([\s\S]*?)\s*<\/final_file_content>/,
)
if (finalContentMatch) {
finalContentMap.set(block.tool_use_id, finalContentMatch[2])
}
}
}
}
// Third pass: convert messages
return messages.map((message) => {
if (!Array.isArray(message.content)) {
return message
}
const convertedContent = message.content.map((block) => {
// Handle tool_use blocks for write_to_file and replace_in_file
if (block.type === "tool_use" && (block.name === "write_to_file" || block.name === "replace_in_file")) {
const finalContent = finalContentMap.get(block.id)
const patchInput = convertToPatchFormat(block.name, block.input, finalContent)
// Update the map with the generated patch
const existingEntry = toolUseIdMap.get(block.id)
if (existingEntry) {
existingEntry.patchInput = patchInput
}
return {
...block,
name: "apply_patch",
input: {
input: patchInput,
},
}
}
// Handle tool_result blocks
if (block.type === "tool_result") {
const conversion = toolUseIdMap.get(block.tool_use_id)
if (conversion) {
// Reconstruct the tool_result content to match apply_patch format
const reconstructedContent = reconstructWriteToFileResult(
block,
conversion.originalName,
conversion.originalInput,
)
return {
...block,
content: reconstructedContent,
}
}
}
return block
})
return {
...message,
content: convertedContent,
}
})
}
/**
* Convert write_to_file or replace_in_file input to apply_patch format
*/
function convertToPatchFormat(toolName: string, input: any, finalContent?: string): string {
const filePath = input.absolutePath || input.path || ""
if (toolName === "write_to_file") {
// Convert write_to_file to Add operation
const content = input.content || ""
const lines = content.split("\n")
const patchLines = ["@@"]
patchLines.push(...lines.map((line: string) => `+ ${line}`))
return `apply_patch <<"EOF"
*** Begin Patch
*** Add File: ${filePath}
${patchLines.join("\n")}
*** End Patch
EOF`
}
if (toolName === "replace_in_file") {
// Convert replace_in_file to Update operation
const diff = input.diff || ""
// Parse SEARCH/REPLACE blocks and convert to V4A format with context
const patchContent = convertDiffToPatchWithContext(diff, finalContent)
return `apply_patch <<"EOF"
*** Begin Patch
*** Update File: ${filePath}
${patchContent}
*** End Patch
EOF`
}
return ""
}
/**
* Convert SEARCH/REPLACE diff format to V4A patch format with additional context from final content
*/
function convertDiffToPatchWithContext(diff: string, finalContent?: string): string {
const patchLines: string[] = []
// Match all SEARCH/REPLACE blocks
const blockRegex = /------- SEARCH\s*\n([\s\S]*?)\n=======\s*\n([\s\S]*?)\n\+{7} REPLACE/g
let match
while ((match = blockRegex.exec(diff)) !== null) {
const searchContent = match[1]
const replaceContent = match[2]
const searchLines = searchContent.split("\n")
const replaceLines = replaceContent.split("\n")
// Find common prefix and suffix between search and replace
let prefixEnd = 0
while (
prefixEnd < searchLines.length &&
prefixEnd < replaceLines.length &&
searchLines[prefixEnd] === replaceLines[prefixEnd]
) {
prefixEnd++
}
let suffixStart = searchLines.length
let replaceSuffixStart = replaceLines.length
while (
suffixStart > prefixEnd &&
replaceSuffixStart > prefixEnd &&
searchLines[suffixStart - 1] === replaceLines[replaceSuffixStart - 1]
) {
suffixStart--
replaceSuffixStart--
}
// If we have finalContent, extract additional context from it
if (finalContent) {
const finalLines = finalContent.split("\n")
// Find where the replaced content appears in the final file
let matchIndex = -1
for (let i = 0; i < finalLines.length; i++) {
// Try to match the first replace line
if (replaceLines.length > 0 && finalLines[i] === replaceLines[0]) {
// Check if subsequent lines also match
let allMatch = true
for (let j = 1; j < replaceLines.length && i + j < finalLines.length; j++) {
if (finalLines[i + j] !== replaceLines[j]) {
allMatch = false
break
}
}
if (allMatch) {
matchIndex = i
break
}
}
}
if (matchIndex >= 0) {
// Extract up to 3 lines before as context
const contextStart = Math.max(0, matchIndex - 3)
const contextLines: string[] = []
for (let i = contextStart; i < matchIndex; i++) {
contextLines.push(finalLines[i])
}
// Pad to 3 lines if needed (with empty strings)
while (contextLines.length < 3) {
contextLines.unshift("")
}
// Add @@ marker with the first context line
if (contextLines[0] === "") {
patchLines.push("@@")
} else {
patchLines.push(`@@${contextLines[0]}`)
}
// Add remaining context lines (without @@ marker)
for (let i = 1; i < contextLines.length; i++) {
patchLines.push(contextLines[i])
}
// Add common prefix lines (without +/- markers)
for (let i = 0; i < prefixEnd; i++) {
patchLines.push(searchLines[i])
}
// Add the actual changes (lines that differ)
for (let i = prefixEnd; i < suffixStart; i++) {
patchLines.push(`- ${searchLines[i]}`)
}
for (let i = prefixEnd; i < replaceSuffixStart; i++) {
patchLines.push(`+ ${replaceLines[i]}`)
}
// Add common suffix lines (without +/- markers)
for (let i = suffixStart; i < searchLines.length; i++) {
patchLines.push(searchLines[i])
}
// Extract up to 3 lines after as trailing context (without @@ markers)
const contextEnd = Math.min(finalLines.length, matchIndex + replaceLines.length + 3)
for (let i = matchIndex + replaceLines.length; i < contextEnd; i++) {
patchLines.push(finalLines[i])
}
continue
}
}
// Fallback: if no finalContent or couldn't find match, use the prefix/suffix from SEARCH/REPLACE
patchLines.push("@@")
// Add common prefix lines (without +/- markers)
for (let i = 0; i < prefixEnd; i++) {
patchLines.push(searchLines[i])
}
// Add the actual changes (lines that differ)
for (let i = prefixEnd; i < suffixStart; i++) {
patchLines.push(`- ${searchLines[i]}`)
}
for (let i = prefixEnd; i < replaceSuffixStart; i++) {
patchLines.push(`+ ${replaceLines[i]}`)
}
// Add common suffix lines (without +/- markers)
for (let i = suffixStart; i < searchLines.length; i++) {
patchLines.push(searchLines[i])
}
}
return patchLines.join("\n")
}
/**
* Reconstruct tool_result content to match apply_patch result format
*/
function reconstructWriteToFileResult(block: any, originalToolName: string, originalInput: any): string | any[] {
// Extract the content from the tool_result
const content = typeof block.content === "string" ? block.content : ""
// Try to extract the final_file_content
const finalContentMatch = content.match(/<final_file_content path="([^"]+)">\s*([\s\S]*?)\s*<\/final_file_content>/)
const filePath = originalInput.absolutePath || originalInput.path || ""
if (!finalContentMatch) {
// If no final_file_content found, create a simple success message
if (originalToolName === "write_to_file") {
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully saved to ${filePath}.\n\nThe file has been created/updated with the new content.`
} else {
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully updated in ${filePath}.\n\nThe file has been modified.`
}
}
const finalContent = finalContentMatch[2]
// Reconstruct the result message based on the original tool type
if (originalToolName === "write_to_file") {
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully saved to ${filePath}.\n\nThe file has been created/updated with the new content.`
}
if (originalToolName === "replace_in_file") {
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully updated in ${filePath}.\n\nThe file has been modified using Update operation.\n\n<final_file_content path="${filePath}">\n${finalContent}\n</final_file_content>\n\nIMPORTANT: For any future changes to this file, use the final_file_content shown above as your reference.`
}
// Default fallback
return block.content
}
@@ -1,60 +0,0 @@
import { ClineStorageMessage } from "@/shared/messages/content"
import { ClineDefaultTool } from "@/shared/tools"
import { convertApplyPatchToolCalls, convertWriteToFileToolCalls } from "./diff-editors"
/**
* Transforms tool call messages between different tool formats based on native tool support.
* Converts between apply_patch and write_to_file/replace_in_file formats as needed.
*
* @param clineMessages - Array of messages containing tool calls to transform
* @param nativeTools - Array of tools natively supported by the current provider
* @returns Transformed messages array, or original if no transformation needed
*/
export function transformToolCallMessages(
clineMessages: ClineStorageMessage[],
nativeTools?: ClineDefaultTool[],
): ClineStorageMessage[] {
// Early return if no messages or native tools provided
if (!clineMessages?.length || !nativeTools?.length) {
return clineMessages
}
// Create Sets for O(1) lookup performance
const nativeToolSet = new Set(nativeTools)
const usedToolSet = new Set<string>()
// Single pass: collect all tools used in assistant messages
for (const msg of clineMessages) {
if (msg.role === "assistant" && Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === "tool_use" && block.name) {
usedToolSet.add(block.name)
}
}
}
}
// Early return if no tools were used
if (usedToolSet.size === 0) {
return clineMessages
}
// Determine which conversion to apply
const hasApplyPatchNative = nativeToolSet.has(ClineDefaultTool.APPLY_PATCH)
const hasFileEditNative = nativeToolSet.has(ClineDefaultTool.FILE_EDIT) || nativeToolSet.has(ClineDefaultTool.FILE_NEW)
const hasApplyPatchUsed = usedToolSet.has(ClineDefaultTool.APPLY_PATCH)
const hasFileEditUsed = usedToolSet.has(ClineDefaultTool.FILE_EDIT) || usedToolSet.has(ClineDefaultTool.FILE_NEW)
// Convert write_to_file/replace_in_file → apply_patch
if (hasApplyPatchNative && hasFileEditUsed) {
return convertWriteToFileToolCalls(clineMessages)
}
// Convert apply_patch → write_to_file/replace_in_file
if (hasFileEditNative && hasApplyPatchUsed) {
return convertApplyPatchToolCalls(clineMessages)
}
return clineMessages
}
+9 -530
View File
@@ -1,72 +1,18 @@
import {
ApiConfiguration,
buildModelInfoNameMap,
clinePassDefaultModelId,
ModelInfo,
QwenApiRegions,
resolveClinePassModelInfo,
} from "@shared/api"
import { ModelInfo } from "@shared/api"
import { Mode } from "@shared/storage/types"
import { ClineStorageMessage } from "@/shared/messages/content"
import { Logger } from "@/shared/services/Logger"
import { ClineTool } from "@/shared/tools"
import { AIhubmixHandler } from "./providers/aihubmix"
import { AnthropicHandler } from "./providers/anthropic"
import { AskSageHandler } from "./providers/asksage"
import { BasetenHandler } from "./providers/baseten"
import { AwsBedrockHandler } from "./providers/bedrock"
import { CerebrasHandler } from "./providers/cerebras"
import { ClaudeCodeHandler } from "./providers/claude-code"
import { ClineHandler } from "./providers/cline"
import { DeepSeekHandler } from "./providers/deepseek"
import { DifyHandler } from "./providers/dify"
import { DoubaoHandler } from "./providers/doubao"
import { FireworksHandler } from "./providers/fireworks"
import { GeminiHandler } from "./providers/gemini"
import { GroqHandler } from "./providers/groq"
import { HicapHandler } from "./providers/hicap"
import { HuaweiCloudMaaSHandler } from "./providers/huawei-cloud-maas"
import { HuggingFaceHandler } from "./providers/huggingface"
import { LiteLlmHandler } from "./providers/litellm"
import { LmStudioHandler } from "./providers/lmstudio"
import { MinimaxHandler } from "./providers/minimax"
import { MistralHandler } from "./providers/mistral"
import { MoonshotHandler } from "./providers/moonshot"
import { NebiusHandler } from "./providers/nebius"
import { NousResearchHandler } from "./providers/nousresearch"
import { OcaHandler } from "./providers/oca"
import { OllamaHandler } from "./providers/ollama"
import { OpenAiHandler } from "./providers/openai"
import { OpenAiCodexHandler } from "./providers/openai-codex"
import { OpenAiNativeHandler } from "./providers/openai-native"
import { OpenRouterHandler } from "./providers/openrouter"
import { QwenHandler } from "./providers/qwen"
import { QwenCodeHandler } from "./providers/qwen-code"
import { RequestyHandler } from "./providers/requesty"
import { SambanovaHandler } from "./providers/sambanova"
import { SapAiCoreHandler } from "./providers/sapaicore"
import { TogetherHandler } from "./providers/together"
import { VercelAIGatewayHandler } from "./providers/vercel-ai-gateway"
import { VertexHandler } from "./providers/vertex"
import { VsCodeLmHandler } from "./providers/vscode-lm"
import { WandbHandler } from "./providers/wandb"
import { XAIHandler } from "./providers/xai"
import { ZAiHandler } from "./providers/zai"
import { ApiStream, ApiStreamUsageChunk } from "./transform/stream"
export type CommonApiHandlerOptions = {
onRetryAttempt?: ApiConfiguration["onRetryAttempt"]
}
export interface ApiHandler {
createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: ClineTool[], useResponseApi?: boolean): ApiStream
getModel(): ApiHandlerModel
getApiStreamUsage?(): Promise<ApiStreamUsageChunk | undefined>
abort?(): void
}
// buildApiHandler now routes inference through the Cline SDK. It lives in
// apps/vscode/src/sdk/sdk-api-handler.ts and callers import it directly from
// there. It is deliberately NOT re-exported here: this barrel is imported
// widely for *types* only, and re-exporting a value from the SDK module would
// pull the entire SDK/session-factory runtime graph into every type importer
// at module-eval time (which can break extension activation). Keep this file
// types-only.
export interface ApiHandlerModel {
id: string
info: ModelInfo
providerId?: string
}
export interface ApiProviderInfo {
@@ -75,470 +21,3 @@ export interface ApiProviderInfo {
mode: Mode
customPrompt?: string // "compact"
}
export interface SingleCompletionHandler {
completePrompt(prompt: string): Promise<string>
}
function createHandlerForProvider(
apiProvider: string | undefined,
options: Omit<ApiConfiguration, "apiProvider">,
mode: Mode,
): ApiHandler {
switch (apiProvider) {
case "anthropic":
return new AnthropicHandler({
onRetryAttempt: options.onRetryAttempt,
apiKey: options.apiKey,
anthropicBaseUrl: options.anthropicBaseUrl,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
})
case "openrouter":
return new OpenRouterHandler({
onRetryAttempt: options.onRetryAttempt,
openRouterApiKey: options.openRouterApiKey,
openRouterModelId: mode === "plan" ? options.planModeOpenRouterModelId : options.actModeOpenRouterModelId,
openRouterModelInfo: mode === "plan" ? options.planModeOpenRouterModelInfo : options.actModeOpenRouterModelInfo,
openRouterProviderSorting: options.openRouterProviderSorting,
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
enableParallelToolCalling: options.enableParallelToolCalling,
})
case "bedrock":
return new AwsBedrockHandler({
onRetryAttempt: options.onRetryAttempt,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
awsAccessKey: options.awsAccessKey,
awsSecretKey: options.awsSecretKey,
awsSessionToken: options.awsSessionToken,
awsRegion: options.awsRegion,
awsAuthentication: options.awsAuthentication,
awsBedrockApiKey: options.awsBedrockApiKey,
awsUseCrossRegionInference: options.awsUseCrossRegionInference,
awsUseGlobalInference: options.awsUseGlobalInference,
awsBedrockUsePromptCache: options.awsBedrockUsePromptCache,
awsUseProfile: options.awsUseProfile,
awsProfile: options.awsProfile,
awsBedrockEndpoint: options.awsBedrockEndpoint,
awsBedrockCustomSelected:
mode === "plan" ? options.planModeAwsBedrockCustomSelected : options.actModeAwsBedrockCustomSelected,
awsBedrockCustomModelBaseId:
mode === "plan" ? options.planModeAwsBedrockCustomModelBaseId : options.actModeAwsBedrockCustomModelBaseId,
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
})
case "vertex":
return new VertexHandler({
onRetryAttempt: options.onRetryAttempt,
vertexProjectId: options.vertexProjectId,
vertexRegion: options.vertexRegion,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
geminiApiKey: options.geminiApiKey,
geminiBaseUrl: options.geminiBaseUrl,
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
ulid: options.ulid,
})
case "openai":
return new OpenAiHandler({
onRetryAttempt: options.onRetryAttempt,
openAiApiKey: options.openAiApiKey,
openAiBaseUrl: options.openAiBaseUrl,
azureApiVersion: options.azureApiVersion,
azureIdentity: options.azureIdentity,
openAiHeaders: options.openAiHeaders,
openAiModelId: mode === "plan" ? options.planModeOpenAiModelId : options.actModeOpenAiModelId,
openAiModelInfo: mode === "plan" ? options.planModeOpenAiModelInfo : options.actModeOpenAiModelInfo,
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
})
case "ollama":
return new OllamaHandler({
onRetryAttempt: options.onRetryAttempt,
ollamaBaseUrl: options.ollamaBaseUrl,
ollamaApiKey: options.ollamaApiKey,
ollamaModelId: mode === "plan" ? options.planModeOllamaModelId : options.actModeOllamaModelId,
ollamaApiOptionsCtxNum: options.ollamaApiOptionsCtxNum,
requestTimeoutMs: options.requestTimeoutMs,
})
case "lmstudio":
return new LmStudioHandler({
onRetryAttempt: options.onRetryAttempt,
lmStudioBaseUrl: options.lmStudioBaseUrl,
lmStudioModelId: mode === "plan" ? options.planModeLmStudioModelId : options.actModeLmStudioModelId,
lmStudioMaxTokens: options.lmStudioMaxTokens,
})
case "gemini":
return new GeminiHandler({
onRetryAttempt: options.onRetryAttempt,
vertexProjectId: options.vertexProjectId,
vertexRegion: options.vertexRegion,
geminiApiKey: options.geminiApiKey,
geminiBaseUrl: options.geminiBaseUrl,
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
ulid: options.ulid,
})
case "openai-native":
return new OpenAiNativeHandler({
onRetryAttempt: options.onRetryAttempt,
openAiNativeApiKey: options.openAiNativeApiKey,
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
})
case "openai-codex":
return new OpenAiCodexHandler({
onRetryAttempt: options.onRetryAttempt,
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
})
case "deepseek":
return new DeepSeekHandler({
onRetryAttempt: options.onRetryAttempt,
deepSeekApiKey: options.deepSeekApiKey,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
})
case "requesty":
return new RequestyHandler({
onRetryAttempt: options.onRetryAttempt,
requestyBaseUrl: options.requestyBaseUrl,
requestyApiKey: options.requestyApiKey,
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
requestyModelId: mode === "plan" ? options.planModeRequestyModelId : options.actModeRequestyModelId,
requestyModelInfo: mode === "plan" ? options.planModeRequestyModelInfo : options.actModeRequestyModelInfo,
})
case "fireworks":
return new FireworksHandler({
onRetryAttempt: options.onRetryAttempt,
fireworksApiKey: options.fireworksApiKey,
fireworksModelId: mode === "plan" ? options.planModeFireworksModelId : options.actModeFireworksModelId,
})
case "together":
return new TogetherHandler({
onRetryAttempt: options.onRetryAttempt,
togetherApiKey: options.togetherApiKey,
togetherModelId: mode === "plan" ? options.planModeTogetherModelId : options.actModeTogetherModelId,
})
case "qwen":
return new QwenHandler({
onRetryAttempt: options.onRetryAttempt,
qwenApiKey: options.qwenApiKey,
qwenApiLine:
options.qwenApiLine === QwenApiRegions.INTERNATIONAL ? QwenApiRegions.INTERNATIONAL : QwenApiRegions.CHINA,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
})
case "qwen-code":
return new QwenCodeHandler({
onRetryAttempt: options.onRetryAttempt,
qwenCodeOauthPath: options.qwenCodeOauthPath,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
})
case "doubao":
return new DoubaoHandler({
onRetryAttempt: options.onRetryAttempt,
doubaoApiKey: options.doubaoApiKey,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
})
case "mistral":
return new MistralHandler({
onRetryAttempt: options.onRetryAttempt,
mistralApiKey: options.mistralApiKey,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
})
case "vscode-lm":
return new VsCodeLmHandler({
onRetryAttempt: options.onRetryAttempt,
vsCodeLmModelSelector:
mode === "plan" ? options.planModeVsCodeLmModelSelector : options.actModeVsCodeLmModelSelector,
})
case "cline": {
const configuredClineModelId = mode === "plan" ? options.planModeClineModelId : options.actModeClineModelId
const configuredClineModelInfo = mode === "plan" ? options.planModeClineModelInfo : options.actModeClineModelInfo
const clineModelId =
configuredClineModelId || (mode === "plan" ? options.planModeOpenRouterModelId : options.actModeOpenRouterModelId)
const clineModelInfo =
configuredClineModelInfo ||
(mode === "plan" ? options.planModeOpenRouterModelInfo : options.actModeOpenRouterModelInfo)
return new ClineHandler({
onRetryAttempt: options.onRetryAttempt,
clineAccountId: options.clineAccountId,
clineApiKey: options.clineApiKey,
ulid: options.ulid,
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
openRouterProviderSorting: options.openRouterProviderSorting,
openRouterModelId: clineModelId,
openRouterModelInfo: clineModelInfo,
enableParallelToolCalling: options.enableParallelToolCalling,
})
}
case "cline-pass": {
const configuredClinePassModelId =
mode === "plan" ? options.planModeClinePassModelId : options.actModeClinePassModelId
const configuredClinePassModelInfo =
mode === "plan" ? options.planModeClinePassModelInfo : options.actModeClinePassModelInfo
const clineModelId = configuredClinePassModelId?.startsWith("cline-pass/")
? configuredClinePassModelId
: clinePassDefaultModelId
const clineModelInfo = resolveClinePassModelInfo(
clineModelId,
configuredClinePassModelInfo
? buildModelInfoNameMap({ [clineModelId]: configuredClinePassModelInfo })
: undefined,
)
return new ClineHandler({
onRetryAttempt: options.onRetryAttempt,
clineAccountId: options.clineAccountId,
clineApiKey: options.clineApiKey,
ulid: options.ulid,
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
openRouterProviderSorting: options.openRouterProviderSorting,
openRouterModelId: clineModelId,
openRouterModelInfo: clineModelInfo,
enableParallelToolCalling: options.enableParallelToolCalling,
})
}
case "litellm":
return new LiteLlmHandler({
onRetryAttempt: options.onRetryAttempt,
liteLlmApiKey: options.liteLlmApiKey,
liteLlmBaseUrl: options.liteLlmBaseUrl,
liteLlmModelId: mode === "plan" ? options.planModeLiteLlmModelId : options.actModeLiteLlmModelId,
liteLlmModelInfo: mode === "plan" ? options.planModeLiteLlmModelInfo : options.actModeLiteLlmModelInfo,
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
liteLlmUsePromptCache: options.liteLlmUsePromptCache,
ulid: options.ulid,
})
case "moonshot":
return new MoonshotHandler({
onRetryAttempt: options.onRetryAttempt,
moonshotApiKey: options.moonshotApiKey,
moonshotApiLine: options.moonshotApiLine,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
})
case "huggingface":
return new HuggingFaceHandler({
onRetryAttempt: options.onRetryAttempt,
huggingFaceApiKey: options.huggingFaceApiKey,
huggingFaceModelId: mode === "plan" ? options.planModeHuggingFaceModelId : options.actModeHuggingFaceModelId,
huggingFaceModelInfo:
mode === "plan" ? options.planModeHuggingFaceModelInfo : options.actModeHuggingFaceModelInfo,
})
case "nebius":
return new NebiusHandler({
onRetryAttempt: options.onRetryAttempt,
nebiusApiKey: options.nebiusApiKey,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
})
case "asksage":
return new AskSageHandler({
onRetryAttempt: options.onRetryAttempt,
asksageApiKey: options.asksageApiKey,
asksageApiUrl: options.asksageApiUrl,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
})
case "xai":
return new XAIHandler({
onRetryAttempt: options.onRetryAttempt,
xaiApiKey: options.xaiApiKey,
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
})
case "sambanova":
return new SambanovaHandler({
onRetryAttempt: options.onRetryAttempt,
sambanovaApiKey: options.sambanovaApiKey,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
})
case "cerebras":
return new CerebrasHandler({
onRetryAttempt: options.onRetryAttempt,
cerebrasApiKey: options.cerebrasApiKey,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
})
case "groq":
return new GroqHandler({
onRetryAttempt: options.onRetryAttempt,
groqApiKey: options.groqApiKey,
groqModelId: mode === "plan" ? options.planModeGroqModelId : options.actModeGroqModelId,
groqModelInfo: mode === "plan" ? options.planModeGroqModelInfo : options.actModeGroqModelInfo,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
})
case "baseten":
return new BasetenHandler({
onRetryAttempt: options.onRetryAttempt,
basetenApiKey: options.basetenApiKey,
basetenModelId: mode === "plan" ? options.planModeBasetenModelId : options.actModeBasetenModelId,
basetenModelInfo: mode === "plan" ? options.planModeBasetenModelInfo : options.actModeBasetenModelInfo,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
})
case "sapaicore":
return new SapAiCoreHandler({
onRetryAttempt: options.onRetryAttempt,
sapAiCoreClientId: options.sapAiCoreClientId,
sapAiCoreClientSecret: options.sapAiCoreClientSecret,
sapAiCoreTokenUrl: options.sapAiCoreTokenUrl,
sapAiResourceGroup: options.sapAiResourceGroup,
sapAiCoreBaseUrl: options.sapAiCoreBaseUrl,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
deploymentId: mode === "plan" ? options.planModeSapAiCoreDeploymentId : options.actModeSapAiCoreDeploymentId,
sapAiCoreUseOrchestrationMode: options.sapAiCoreUseOrchestrationMode,
})
case "claude-code":
return new ClaudeCodeHandler({
onRetryAttempt: options.onRetryAttempt,
claudeCodePath: options.claudeCodePath,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
})
case "huawei-cloud-maas":
return new HuaweiCloudMaaSHandler({
onRetryAttempt: options.onRetryAttempt,
huaweiCloudMaasApiKey: options.huaweiCloudMaasApiKey,
huaweiCloudMaasModelId:
mode === "plan" ? options.planModeHuaweiCloudMaasModelId : options.actModeHuaweiCloudMaasModelId,
huaweiCloudMaasModelInfo:
mode === "plan" ? options.planModeHuaweiCloudMaasModelInfo : options.actModeHuaweiCloudMaasModelInfo,
})
case "dify": // Add Dify.ai handler
return new DifyHandler({
difyApiKey: options.difyApiKey,
difyBaseUrl: options.difyBaseUrl,
})
case "vercel-ai-gateway":
return new VercelAIGatewayHandler({
onRetryAttempt: options.onRetryAttempt,
vercelAiGatewayApiKey: options.vercelAiGatewayApiKey,
openRouterModelId:
mode === "plan" ? options.planModeVercelAiGatewayModelId : options.actModeVercelAiGatewayModelId,
openRouterModelInfo:
mode === "plan" ? options.planModeVercelAiGatewayModelInfo : options.actModeVercelAiGatewayModelInfo,
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
})
case "zai":
return new ZAiHandler({
onRetryAttempt: options.onRetryAttempt,
zaiApiLine: options.zaiApiLine,
zaiApiKey: options.zaiApiKey,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
})
case "oca":
return new OcaHandler({
ocaMode: options.ocaMode || "internal",
ocaBaseUrl: options.ocaBaseUrl,
ocaModelId: mode === "plan" ? options.planModeOcaModelId : options.actModeOcaModelId,
ocaModelInfo: mode === "plan" ? options.planModeOcaModelInfo : options.actModeOcaModelInfo,
ocaReasoningEffort: mode === "plan" ? options.planModeOcaReasoningEffort : options.actModeOcaReasoningEffort,
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
ocaUsePromptCache:
mode === "plan"
? options.planModeOcaModelInfo?.supportsPromptCache
: options.actModeOcaModelInfo?.supportsPromptCache,
taskId: options.ulid,
})
case "aihubmix":
return new AIhubmixHandler({
onRetryAttempt: options.onRetryAttempt,
apiKey: options.aihubmixApiKey,
baseURL: options.aihubmixBaseUrl,
appCode: options.aihubmixAppCode,
modelId: mode === "plan" ? (options as any).planModeAihubmixModelId : (options as any).actModeAihubmixModelId,
modelInfo:
mode === "plan" ? (options as any).planModeAihubmixModelInfo : (options as any).actModeAihubmixModelInfo,
})
case "minimax":
return new MinimaxHandler({
onRetryAttempt: options.onRetryAttempt,
minimaxApiKey: options.minimaxApiKey,
minimaxApiLine: options.minimaxApiLine,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
})
case "hicap":
return new HicapHandler({
onRetryAttempt: options.onRetryAttempt,
hicapApiKey: options.hicapApiKey,
hicapModelId: mode === "plan" ? options.planModeHicapModelId : options.actModeHicapModelId,
})
case "nousResearch":
return new NousResearchHandler({
onRetryAttempt: options.onRetryAttempt,
nousResearchApiKey: options.nousResearchApiKey,
apiModelId: mode === "plan" ? options.planModeNousResearchModelId : options.actModeNousResearchModelId,
})
case "wandb":
return new WandbHandler({
onRetryAttempt: options.onRetryAttempt,
wandbApiKey: options.wandbApiKey,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
})
default:
return new AnthropicHandler({
onRetryAttempt: options.onRetryAttempt,
apiKey: options.apiKey,
anthropicBaseUrl: options.anthropicBaseUrl,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
})
}
}
export function buildApiHandler(configuration: ApiConfiguration, mode: Mode): ApiHandler {
const { planModeApiProvider, actModeApiProvider, ...options } = configuration
const apiProvider = mode === "plan" ? planModeApiProvider : actModeApiProvider
// Validate thinking budget tokens against model's maxTokens to prevent API errors
// wrapped in a try-catch for safety, but this should never throw
try {
const thinkingBudgetTokens = mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens
if (thinkingBudgetTokens && thinkingBudgetTokens > 0) {
const handler = createHandlerForProvider(apiProvider, options, mode)
const modelInfo = handler.getModel().info
if (modelInfo?.maxTokens && modelInfo.maxTokens > 0 && thinkingBudgetTokens > modelInfo.maxTokens) {
const clippedValue = modelInfo.maxTokens - 1
if (mode === "plan") {
options.planModeThinkingBudgetTokens = clippedValue
} else {
options.actModeThinkingBudgetTokens = clippedValue
}
} else {
return handler // don't rebuild unless its necessary
}
}
} catch (error) {
Logger.error("buildApiHandler error:", error)
}
return createHandlerForProvider(apiProvider, options, mode)
}
@@ -1,260 +0,0 @@
import { afterEach, describe, it } from "mocha"
import sinon from "sinon"
import "should"
import { anthropicModels } from "@shared/api"
import { ANTHROPIC_FAST_MODE_BETA, AnthropicHandler } from "../anthropic"
describe("AnthropicHandler", () => {
afterEach(() => {
sinon.restore()
})
const createAsyncIterable = (data: readonly unknown[] = []) => ({
[Symbol.asyncIterator]: async function* () {
yield* data
},
})
describe("getModel", () => {
it("should return the fast mode model when configured", () => {
const handler = new AnthropicHandler({
apiKey: "test-api-key",
apiModelId: "claude-opus-4-6:fast",
})
const result = handler.getModel()
result.id.should.equal("claude-opus-4-6:fast")
result.info.should.deepEqual(anthropicModels["claude-opus-4-6:fast"])
})
it("should return the 1m fast mode model when configured", () => {
const handler = new AnthropicHandler({
apiKey: "test-api-key",
apiModelId: "claude-opus-4-6:1m:fast",
})
const result = handler.getModel()
result.id.should.equal("claude-opus-4-6:1m:fast")
result.info.should.deepEqual(anthropicModels["claude-opus-4-6:1m:fast"])
})
it("should return the 4.7 model when configured", () => {
const handler = new AnthropicHandler({
apiKey: "test-api-key",
apiModelId: "claude-opus-4-7",
})
const result = handler.getModel()
result.id.should.equal("claude-opus-4-7")
result.info.should.deepEqual(anthropicModels["claude-opus-4-7"])
})
it("should return the 4.7 1m model when configured", () => {
const handler = new AnthropicHandler({
apiKey: "test-api-key",
apiModelId: "claude-opus-4-7:1m",
})
const result = handler.getModel()
result.id.should.equal("claude-opus-4-7:1m")
result.info.should.deepEqual(anthropicModels["claude-opus-4-7:1m"])
})
it("should return the 4.8 model when configured", () => {
const handler = new AnthropicHandler({
apiKey: "test-api-key",
apiModelId: "claude-opus-4-8",
})
const result = handler.getModel()
result.id.should.equal("claude-opus-4-8")
result.info.should.deepEqual(anthropicModels["claude-opus-4-8"])
})
it("should return the 4.8 1m model when configured", () => {
const handler = new AnthropicHandler({
apiKey: "test-api-key",
apiModelId: "claude-opus-4-8:1m",
})
const result = handler.getModel()
result.id.should.equal("claude-opus-4-8:1m")
result.info.should.deepEqual(anthropicModels["claude-opus-4-8:1m"])
})
it("should return the Fable 5 model when configured", () => {
const handler = new AnthropicHandler({
apiKey: "test-api-key",
apiModelId: "claude-fable-5",
})
const result = handler.getModel()
result.id.should.equal("claude-fable-5")
result.info.should.deepEqual(anthropicModels["claude-fable-5"])
})
it("should return the Fable 5 1m model when configured", () => {
const handler = new AnthropicHandler({
apiKey: "test-api-key",
apiModelId: "claude-fable-5:1m",
})
const result = handler.getModel()
result.id.should.equal("claude-fable-5:1m")
result.info.should.deepEqual(anthropicModels["claude-fable-5:1m"])
})
})
describe("createMessage", () => {
it("should route fast mode requests through the beta messages API", async () => {
const handler = new AnthropicHandler({
apiKey: "test-api-key",
apiModelId: "claude-opus-4-6:fast",
})
const standardCreate = sinon.stub().resolves(createAsyncIterable())
const betaCreate = sinon.stub().callsFake(function (this: { _client?: object }, _params: unknown) {
should.exist(this._client)
return Promise.resolve(createAsyncIterable())
})
sinon.stub(handler as unknown as { ensureClient: () => unknown }, "ensureClient").returns({
messages: {
create: standardCreate,
},
beta: {
messages: {
_client: {},
create: betaCreate,
},
},
})
for await (const _chunk of handler.createMessage("system prompt", [{ role: "user", content: "Hello" }])) {
}
sinon.assert.notCalled(standardCreate)
sinon.assert.calledOnce(betaCreate)
sinon.assert.calledWithMatch(betaCreate, {
model: "claude-opus-4-6",
betas: [ANTHROPIC_FAST_MODE_BETA],
speed: "fast",
stream: true,
})
})
it("should include the 1m beta when routing 1m fast mode requests through the beta messages API", async () => {
const handler = new AnthropicHandler({
apiKey: "test-api-key",
apiModelId: "claude-opus-4-6:1m:fast",
})
const standardCreate = sinon.stub().resolves(createAsyncIterable())
const betaCreate = sinon.stub().callsFake(function (this: { _client?: object }, _params: unknown) {
should.exist(this._client)
return Promise.resolve(createAsyncIterable())
})
sinon.stub(handler as unknown as { ensureClient: () => unknown }, "ensureClient").returns({
messages: {
create: standardCreate,
},
beta: {
messages: {
_client: {},
create: betaCreate,
},
},
})
for await (const _chunk of handler.createMessage("system prompt", [{ role: "user", content: "Hello" }])) {
}
sinon.assert.notCalled(standardCreate)
sinon.assert.calledOnce(betaCreate)
sinon.assert.calledWithMatch(betaCreate, {
model: "claude-opus-4-6",
betas: [ANTHROPIC_FAST_MODE_BETA, "context-1m-2025-08-07"],
speed: "fast",
stream: true,
})
})
it("should include the 1m beta header for Claude Opus 4.7 1m requests", async () => {
const handler = new AnthropicHandler({
apiKey: "test-api-key",
apiModelId: "claude-opus-4-7:1m",
reasoningEffort: "high",
})
const standardCreate = sinon.stub().resolves(createAsyncIterable())
sinon.stub(handler as unknown as { ensureClient: () => unknown }, "ensureClient").returns({
messages: {
create: standardCreate,
},
beta: {
messages: {
_client: {},
create: sinon.stub().resolves(createAsyncIterable()),
},
},
})
for await (const _chunk of handler.createMessage("system prompt", [{ role: "user", content: "Hello" }])) {
}
sinon.assert.calledOnce(standardCreate)
const requestBody = standardCreate.firstCall.args[0] as Record<string, any>
const requestOptions = standardCreate.firstCall.args[1] as Record<string, any>
requestBody.model.should.equal("claude-opus-4-7")
requestBody.thinking.should.deepEqual({ type: "adaptive" })
requestOptions.should.deepEqual({
headers: {
"anthropic-beta": "context-1m-2025-08-07",
},
})
})
it("should use adaptive thinking and output_config for Claude Opus adaptive models", async () => {
const handler = new AnthropicHandler({
apiKey: "test-api-key",
apiModelId: "claude-opus-4-7",
reasoningEffort: "xhigh",
})
const standardCreate = sinon.stub().resolves(createAsyncIterable())
sinon.stub(handler as unknown as { ensureClient: () => unknown }, "ensureClient").returns({
messages: {
create: standardCreate,
},
beta: {
messages: {
_client: {},
create: sinon.stub().resolves(createAsyncIterable()),
},
},
})
for await (const _chunk of handler.createMessage("system prompt", [{ role: "user", content: "Hello" }])) {
}
sinon.assert.calledOnce(standardCreate)
const requestBody = standardCreate.firstCall.args[0] as Record<string, any>
requestBody.should.have.property("thinking")
requestBody.thinking.should.deepEqual({ type: "adaptive" })
requestBody.should.have.property("output_config")
requestBody.output_config.should.deepEqual({ effort: "xhigh" })
should(requestBody.temperature).equal(undefined)
})
})
})
File diff suppressed because it is too large Load Diff
@@ -1,488 +0,0 @@
import { afterEach, beforeEach, describe, it } from "mocha"
import sinon from "sinon"
import "should"
import { ClaudeCodeHandler } from "@core/api/providers/claude-code"
import { ClineStorageMessage } from "@/shared/messages/content"
describe("ClaudeCodeHandler", () => {
let handler: ClaudeCodeHandler
let sandbox: sinon.SinonSandbox
beforeEach(() => {
sandbox = sinon.createSandbox()
handler = new ClaudeCodeHandler({
claudeCodePath: "/mock/path",
apiModelId: "claude-opus-4-1-20250805",
})
})
afterEach(() => {
sandbox.restore()
})
describe("token counting", () => {
it("should correctly handle token usage from assistant messages", async () => {
// The 'input_tokens' field represents the TOTAL number of input tokens used.
// See https://docs.anthropic.com/en/api/messages#usage-object
// Mock the runClaudeCode function
const runClaudeCodeModule = await import("@/integrations/claude-code/run")
const runClaudeCodeStub = sandbox.stub(runClaudeCodeModule, "runClaudeCode")
// Create a proper async generator mock for the Claude Code response
async function* mockGenerator() {
// First yield the system init
yield {
type: "system",
subtype: "init",
apiKeySource: "api",
}
// Yield assistant message with usage data
// Example: If base input is 70 tokens, cache read is 20, and cache creation is 10,
// then input_tokens from Anthropic API will be 100 (70 + 20 + 10)
yield {
type: "assistant",
message: {
content: [
{
type: "text",
text: "Test response",
},
],
usage: {
input_tokens: 100, // Total including cache (per Anthropic docs)
output_tokens: 50,
cache_read_input_tokens: 20, // Already included in input_tokens
cache_creation_input_tokens: 10, // Already included in input_tokens
},
stop_reason: "end_turn",
},
}
// Yield result with cost
yield {
type: "result",
result: {},
total_cost_usd: 0.005,
}
}
runClaudeCodeStub.returns(mockGenerator() as any)
const systemPrompt = "You are a helpful assistant."
const messages: ClineStorageMessage[] = [{ role: "user", content: "Hello" }]
const usageData: any[] = []
// Collect the results
for await (const chunk of handler.createMessage(systemPrompt, messages)) {
if (chunk.type === "usage") {
usageData.push({
inputTokens: chunk.inputTokens,
outputTokens: chunk.outputTokens,
cacheReadTokens: chunk.cacheReadTokens,
cacheWriteTokens: chunk.cacheWriteTokens,
totalCost: chunk.totalCost,
})
}
}
// Verify token counting follows Anthropic API specification
usageData.should.have.length(1)
usageData[0].should.deepEqual({
inputTokens: 100, // Total including cache tokens (per Anthropic API docs)
outputTokens: 50,
cacheReadTokens: 20, // Tracked separately for reporting
cacheWriteTokens: 10, // Tracked separately for reporting
totalCost: 0.005,
})
// CRITICAL ASSERTION: Verify that input_tokens is NOT inflated by re-adding cache tokens
// The bug would have caused inputTokens to be incorrectly calculated as 130 (100 + 20 + 10)
// The fix ensures it remains 100, as per Anthropic's specification
usageData[0].inputTokens.should.equal(100) // Correct: matches API response
usageData[0].inputTokens.should.not.equal(130) // Would be wrong: double-counting cache tokens
})
it("should handle missing usage fields with nullish coalescing", async () => {
// Mock the runClaudeCode function
const runClaudeCodeModule = await import("@/integrations/claude-code/run")
const runClaudeCodeStub = sandbox.stub(runClaudeCodeModule, "runClaudeCode")
// Create a proper async generator mock with missing/undefined usage fields
async function* mockGenerator() {
yield {
type: "assistant",
message: {
content: [
{
type: "text",
text: "Test response",
},
],
usage: {
input_tokens: 100,
output_tokens: 50,
// cache fields are undefined/missing
},
stop_reason: "end_turn",
},
}
yield {
type: "result",
result: {},
total_cost_usd: 0.005,
}
}
runClaudeCodeStub.returns(mockGenerator() as any)
const systemPrompt = "You are a helpful assistant."
const messages: ClineStorageMessage[] = [{ role: "user", content: "Hello" }]
const usageData: any[] = []
// Collect the results
for await (const chunk of handler.createMessage(systemPrompt, messages)) {
if (chunk.type === "usage") {
usageData.push({
inputTokens: chunk.inputTokens,
outputTokens: chunk.outputTokens,
cacheReadTokens: chunk.cacheReadTokens,
cacheWriteTokens: chunk.cacheWriteTokens,
})
}
}
// Verify that undefined cache tokens default to 0
usageData.should.have.length(1)
usageData[0].should.deepEqual({
inputTokens: 100,
outputTokens: 50,
cacheReadTokens: 0, // Should default to 0
cacheWriteTokens: 0, // Should default to 0
})
})
it("should handle completely missing usage object", async () => {
// Mock the runClaudeCode function
const runClaudeCodeModule = await import("@/integrations/claude-code/run")
const runClaudeCodeStub = sandbox.stub(runClaudeCodeModule, "runClaudeCode")
// Create a proper async generator mock with missing usage object
async function* mockGenerator() {
yield {
type: "assistant",
message: {
content: [
{
type: "text",
text: "Test response",
},
],
// usage is undefined
usage: undefined,
stop_reason: "end_turn",
},
}
// Need to yield a result chunk to trigger usage data emission
yield {
type: "result",
result: {},
total_cost_usd: 0,
}
}
runClaudeCodeStub.returns(mockGenerator() as any)
const systemPrompt = "You are a helpful assistant."
const messages: ClineStorageMessage[] = [{ role: "user", content: "Hello" }]
const usageData: any[] = []
// Collect the results
for await (const chunk of handler.createMessage(systemPrompt, messages)) {
if (chunk.type === "usage") {
usageData.push({
inputTokens: chunk.inputTokens,
outputTokens: chunk.outputTokens,
cacheReadTokens: chunk.cacheReadTokens,
cacheWriteTokens: chunk.cacheWriteTokens,
})
}
}
// All token counts should default to 0 when usage is undefined
usageData.should.have.length(1)
usageData[0].should.deepEqual({
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
})
})
})
describe("error handling", () => {
it("should not crash when assistant message has empty content array", async () => {
const runClaudeCodeModule = await import("@/integrations/claude-code/run")
const runClaudeCodeStub = sandbox.stub(runClaudeCodeModule, "runClaudeCode")
async function* mockGenerator() {
yield {
type: "assistant",
message: {
content: [], // empty content — triggered TypeError in older code
usage: {
input_tokens: 10,
output_tokens: 0,
},
stop_reason: "end_turn",
},
}
yield {
type: "result",
result: {},
total_cost_usd: 0,
}
}
runClaudeCodeStub.returns(mockGenerator() as any)
const chunks: any[] = []
// Should not throw
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
chunks.push(chunk)
}
const usageChunk = chunks.find((c) => c.type === "usage")
usageChunk.should.be.ok()
usageChunk.inputTokens.should.equal(10)
})
it("should throw when result has is_error=true (e.g. rate limit with no assistant message)", async () => {
const runClaudeCodeModule = await import("@/integrations/claude-code/run")
const runClaudeCodeStub = sandbox.stub(runClaudeCodeModule, "runClaudeCode")
async function* mockGenerator() {
yield {
type: "system",
subtype: "init",
apiKeySource: "none",
}
yield {
type: "system",
subtype: "rate_limit_event",
message: "Rate limit hit",
retryAfterSeconds: 30,
}
// No assistant message — CLI hit rate limit and gave up
yield {
type: "result",
subtype: "error",
is_error: true,
result: "Rate limit exceeded",
total_cost_usd: 0,
duration_ms: 1000,
duration_api_ms: 500,
num_turns: 0,
session_id: "test",
}
}
runClaudeCodeStub.returns(mockGenerator() as any)
let thrownError: Error | undefined
try {
for await (const _ of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
// consume
}
} catch (err) {
thrownError = err as Error
}
thrownError!.message.should.containEql("Rate limit exceeded")
})
it("should ignore rate_limit_event system messages without throwing", async () => {
const runClaudeCodeModule = await import("@/integrations/claude-code/run")
const runClaudeCodeStub = sandbox.stub(runClaudeCodeModule, "runClaudeCode")
async function* mockGenerator() {
yield {
type: "system",
subtype: "init",
apiKeySource: "none",
}
// Newer Claude Code CLI emits this during rate limiting
yield {
type: "system",
subtype: "rate_limit_event",
message: "Rate limit hit, retrying...",
retryAfterSeconds: 30,
}
yield {
type: "assistant",
message: {
content: [{ type: "text", text: "Response after retry" }],
usage: { input_tokens: 20, output_tokens: 10 },
stop_reason: "end_turn",
},
}
yield {
type: "result",
result: {},
total_cost_usd: 0,
}
}
runClaudeCodeStub.returns(mockGenerator() as any)
const textChunks: string[] = []
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
if (chunk.type === "text") textChunks.push(chunk.text)
}
textChunks.should.deepEqual(["Response after retry"])
})
})
describe("getModel", () => {
it("should return the correct model when specified", () => {
const handler = new ClaudeCodeHandler({
apiModelId: "claude-sonnet-4-5-20250929",
})
const model = handler.getModel()
model.id.should.equal("claude-sonnet-4-5-20250929")
})
it("should support Opus 4.6 1m model id", () => {
const handler = new ClaudeCodeHandler({
apiModelId: "claude-opus-4-6[1m]",
})
const model = handler.getModel()
model.id.should.equal("claude-opus-4-6[1m]")
model.info.contextWindow.should.equal(1_000_000)
})
it("should support Opus 4.7 model id", () => {
const handler = new ClaudeCodeHandler({
apiModelId: "claude-opus-4-7",
})
const model = handler.getModel()
model.id.should.equal("claude-opus-4-7")
model.info.contextWindow.should.equal(200_000)
})
it("should support Opus 4.7 1m model id", () => {
const handler = new ClaudeCodeHandler({
apiModelId: "claude-opus-4-7[1m]",
})
const model = handler.getModel()
model.id.should.equal("claude-opus-4-7[1m]")
model.info.contextWindow.should.equal(1_000_000)
})
it("should support Opus 4.8 model id", () => {
const handler = new ClaudeCodeHandler({
apiModelId: "claude-opus-4-8",
})
const model = handler.getModel()
model.id.should.equal("claude-opus-4-8")
model.info.contextWindow.should.equal(200_000)
})
it("should support Opus 4.8 1m model id", () => {
const handler = new ClaudeCodeHandler({
apiModelId: "claude-opus-4-8[1m]",
})
const model = handler.getModel()
model.id.should.equal("claude-opus-4-8[1m]")
model.info.contextWindow.should.equal(1_000_000)
})
it("should support Fable 5 model id", () => {
const handler = new ClaudeCodeHandler({
apiModelId: "claude-fable-5",
})
const model = handler.getModel()
model.id.should.equal("claude-fable-5")
model.info.contextWindow.should.equal(200_000)
})
it("should support Fable 5 1m model id", () => {
const handler = new ClaudeCodeHandler({
apiModelId: "claude-fable-5[1m]",
})
const model = handler.getModel()
model.id.should.equal("claude-fable-5[1m]")
model.info.contextWindow.should.equal(1_000_000)
})
it("should support Opus 1m alias model id", () => {
const handler = new ClaudeCodeHandler({
apiModelId: "opus[1m]",
})
const model = handler.getModel()
model.id.should.equal("opus[1m]")
model.info.contextWindow.should.equal(1_000_000)
})
it("should support Sonnet 1m alias model id", () => {
const handler = new ClaudeCodeHandler({
apiModelId: "sonnet[1m]",
})
const model = handler.getModel()
model.id.should.equal("sonnet[1m]")
model.info.contextWindow.should.equal(1_000_000)
})
it("should support Sonnet 4.5 1m model id", () => {
const handler = new ClaudeCodeHandler({
apiModelId: "claude-sonnet-4-5-20250929[1m]",
})
const model = handler.getModel()
model.id.should.equal("claude-sonnet-4-5-20250929[1m]")
model.info.contextWindow.should.equal(1_000_000)
})
it("should support Sonnet 4.6 1m model id", () => {
const handler = new ClaudeCodeHandler({
apiModelId: "claude-sonnet-4-6[1m]",
})
const model = handler.getModel()
model.id.should.equal("claude-sonnet-4-6[1m]")
model.info.contextWindow.should.equal(1_000_000)
})
it("should return default model when not specified", () => {
const handler = new ClaudeCodeHandler({})
const model = handler.getModel()
// The default model should be set
model.id.should.be.type("string")
model.info.should.be.type("object")
})
})
})
@@ -1,197 +0,0 @@
import "should"
import { openRouterDefaultModelInfo } from "@shared/api"
import sinon from "sinon"
import { ClineAccountService } from "@/services/account/ClineAccountService"
import { AuthService } from "@/services/auth/AuthService"
import { ClineError, ClineErrorType } from "@/services/error/ClineError"
import { ClineHandler } from "../cline"
describe("ClineHandler", () => {
afterEach(() => {
sinon.restore()
})
const createAsyncIterable = (data: any[] = []) => ({
[Symbol.asyncIterator]: async function* () {
yield* data
},
})
const createHandler = (options: ConstructorParameters<typeof ClineHandler>[0]) => {
sinon.stub(ClineAccountService, "getInstance").returns({} as any)
sinon.stub(AuthService, "getInstance").returns({} as any)
return new ClineHandler(options)
}
it("should handle usage-only chunks when delta is missing", async () => {
const handler = createHandler({})
const fakeClient = {
chat: {
completions: {
create: sinon.stub().resolves(
createAsyncIterable([
{
choices: [{}],
usage: {
prompt_tokens: 17,
completion_tokens: 9,
},
},
]),
),
},
},
}
sinon.stub(handler as any, "ensureClient").resolves(fakeClient as any)
sinon.stub(handler, "getModel").returns({
id: "openai/gpt-4o-mini",
info: openRouterDefaultModelInfo,
})
const chunks: any[] = []
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
chunks.push(chunk)
}
chunks.should.deepEqual([
{
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: 0,
inputTokens: 17,
outputTokens: 9,
totalCost: 0,
},
])
})
it("should read Anthropic-style cache creation and read tokens from usage chunks", async () => {
const handler = createHandler({})
const fakeClient = {
chat: {
completions: {
create: sinon.stub().resolves(
createAsyncIterable([
{
choices: [{}],
usage: {
prompt_tokens: 1000,
completion_tokens: 200,
prompt_tokens_details: {
cached_tokens: 500,
},
cache_creation_input_tokens: 300,
},
},
]),
),
},
},
}
sinon.stub(handler as any, "ensureClient").resolves(fakeClient as any)
sinon.stub(handler, "getModel").returns({
id: "anthropic/claude-sonnet-4.6",
info: openRouterDefaultModelInfo,
})
const chunks: any[] = []
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
chunks.push(chunk)
}
chunks.should.deepEqual([
{
type: "usage",
cacheWriteTokens: 300,
cacheReadTokens: 500,
inputTokens: 200,
outputTokens: 200,
totalCost: 0,
},
])
})
it("should forward enableParallelToolCalling to OpenRouter payload", async () => {
const handler = createHandler({ enableParallelToolCalling: true })
const createStub = sinon.stub().resolves(createAsyncIterable([]))
const fakeClient = {
chat: {
completions: {
create: createStub,
},
},
}
sinon.stub(handler as any, "ensureClient").resolves(fakeClient as any)
sinon.stub(handler, "getModel").returns({
id: "openai/gpt-4o-mini",
info: openRouterDefaultModelInfo,
})
const tools = [
{ type: "function", function: { name: "read_file", description: "", parameters: { type: "object" } } },
] as any
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "hi" }], tools)) {
// drain stream
}
const payload = createStub.firstCall.args[0]
payload.parallel_tool_calls.should.equal(true)
})
it("should send cache_control for qwen3.7-max without changing the selected Cline model id", async () => {
const handler = createHandler({
openRouterModelId: "qwen/qwen3.7-max",
openRouterModelInfo: openRouterDefaultModelInfo,
})
const createStub = sinon.stub().resolves(createAsyncIterable([]))
const fakeClient = {
chat: {
completions: {
create: createStub,
},
},
}
sinon.stub(handler as any, "ensureClient").resolves(fakeClient as any)
sinon.stub(handler as any, "getFreeModelIdSet").resolves(new Set())
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
// drain stream
}
handler.getModel().id.should.equal("qwen/qwen3.7-max")
const payload = createStub.firstCall.args[0]
payload.model.should.equal("qwen/qwen3.7-max")
payload.messages[0].content[0].cache_control.should.deepEqual({ type: "ephemeral" })
payload.messages[1].content[0].cache_control.should.deepEqual({ type: "ephemeral" })
})
it("propagates a pre-stream 403 entitlement error so it classifies as Entitlement", async () => {
const handler = createHandler({})
// A 403 from ValidateModelEntitlement rejects completions.create() before streaming,
// matching the OpenAI SDK APIError shape (status + code + error body).
const apiError = Object.assign(new Error("403 the user is not subscribed to required model plan"), {
status: 403,
code: "ENTITLEMENT_ERROR",
error: {
code: "ENTITLEMENT_ERROR",
message: "Error 403: the user is not subscribed to required model plan",
},
})
const fakeClient = { chat: { completions: { create: sinon.stub().rejects(apiError) } } }
sinon.stub(handler as any, "ensureClient").resolves(fakeClient as any)
sinon.stub(handler as any, "getFreeModelIdSet").resolves(new Set())
sinon.stub(handler, "getModel").returns({ id: "cline-pass/glm-5.1", info: openRouterDefaultModelInfo })
let thrown: unknown
try {
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
// drain
}
} catch (e) {
thrown = e
}
const clineError = ClineError.transform(thrown, "cline-pass/glm-5.1", "cline-pass")
clineError.isErrorType(ClineErrorType.Entitlement).should.be.true()
})
})
@@ -1,97 +0,0 @@
import "should"
import sinon from "sinon"
import { FireworksHandler } from "../fireworks"
describe("FireworksHandler", () => {
afterEach(() => {
sinon.restore()
})
const createAsyncIterable = (data: any[] = []) => ({
[Symbol.asyncIterator]: async function* () {
yield* data
},
})
it("should handle usage-only chunks when delta is missing", async () => {
const handler = new FireworksHandler({
fireworksApiKey: "test-api-key",
fireworksModelId: "accounts/fireworks/models/llama-v3p1-8b-instruct",
})
const fakeClient = {
chat: {
completions: {
create: sinon.stub().resolves(
createAsyncIterable([
{
choices: [{}],
usage: {
prompt_tokens: 19,
completion_tokens: 4,
},
},
]),
),
},
},
}
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
const chunks: any[] = []
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
chunks.push(chunk)
}
chunks.should.deepEqual([
{
type: "usage",
inputTokens: 19,
outputTokens: 4,
cacheReadTokens: 0,
cacheWriteTokens: 0,
},
])
})
it("should read cache hits from prompt_tokens_details when hit tokens are not present", async () => {
const handler = new FireworksHandler({
fireworksApiKey: "test-api-key",
fireworksModelId: "accounts/fireworks/models/llama-v3p1-8b-instruct",
})
const fakeClient = {
chat: {
completions: {
create: sinon.stub().resolves(
createAsyncIterable([
{
choices: [{}],
usage: {
prompt_tokens: 60,
completion_tokens: 12,
prompt_tokens_details: { cached_tokens: 20 },
prompt_cache_miss_tokens: 40,
},
},
]),
),
},
},
}
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
const chunks: any[] = []
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
chunks.push(chunk)
}
chunks.should.deepEqual([
{
type: "usage",
inputTokens: 60,
outputTokens: 12,
cacheReadTokens: 20,
cacheWriteTokens: 40,
},
])
})
})
@@ -1,235 +0,0 @@
import "should"
import sinon from "sinon"
import { GeminiHandler } from "../gemini"
describe("GeminiHandler", () => {
afterEach(() => {
sinon.restore()
})
const createAsyncIterable = (data: any[] = []) => ({
[Symbol.asyncIterator]: async function* () {
yield* data
},
})
it("caps maxOutputTokens to 8192 for Flash models", async () => {
const handler = new GeminiHandler({
geminiApiKey: "test-api-key",
apiModelId: "gemini-2.5-flash",
})
const generateContentStream = sinon.stub().resolves(
createAsyncIterable([
{
responseId: "resp-1",
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 20,
cachedContentTokenCount: 0,
thoughtsTokenCount: 0,
},
},
]),
)
sinon.stub(handler as any, "ensureClient").returns({
models: { generateContentStream },
} as any)
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "hi" }] as any)) {
// Consume stream to trigger request execution.
}
const requestArgs = generateContentStream.firstCall.args[0] as Record<string, any>
requestArgs.config.should.have.property("maxOutputTokens", 8_192)
})
it("supports Gemini 3.5 Flash model metadata", async () => {
const handler = new GeminiHandler({
geminiApiKey: "test-api-key",
apiModelId: "gemini-3.5-flash",
})
const model = handler.getModel()
model.id.should.equal("gemini-3.5-flash")
model.info.contextWindow!.should.equal(1_048_576)
model.info.inputPrice!.should.equal(1.5)
model.info.outputPrice!.should.equal(9)
model.info.cacheReadsPrice!.should.equal(0.15)
model.info.supportsReasoning!.should.equal(true)
const generateContentStream = sinon.stub().resolves(
createAsyncIterable([
{
responseId: "resp-35",
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 20,
cachedContentTokenCount: 0,
thoughtsTokenCount: 0,
},
},
]),
)
sinon.stub(handler as any, "ensureClient").returns({
models: { generateContentStream },
} as any)
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "hi" }] as any)) {
// Consume stream to trigger request execution.
}
const requestArgs = generateContentStream.firstCall.args[0] as Record<string, any>
requestArgs.model.should.equal("gemini-3.5-flash")
requestArgs.config.should.have.property("maxOutputTokens", 8_192)
requestArgs.config.thinkingConfig.should.deepEqual({
thinkingBudget: undefined,
thinkingLevel: "LOW",
includeThoughts: true,
})
})
it("does not set maxOutputTokens for non-Flash models", async () => {
const handler = new GeminiHandler({
geminiApiKey: "test-api-key",
apiModelId: "gemini-2.5-pro",
})
const generateContentStream = sinon.stub().resolves(
createAsyncIterable([
{
responseId: "resp-2",
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 20,
cachedContentTokenCount: 0,
thoughtsTokenCount: 0,
},
},
]),
)
sinon.stub(handler as any, "ensureClient").returns({
models: { generateContentStream },
} as any)
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "hi" }] as any)) {
// Consume stream to trigger request execution.
}
const requestArgs = generateContentStream.firstCall.args[0] as Record<string, any>
requestArgs.config.should.not.have.property("maxOutputTokens")
})
it("should emit unique tool call IDs when multiple function calls share one responseId", async () => {
const handler = new GeminiHandler({
geminiApiKey: "test-api-key",
})
const fakeClient = {
models: {
generateContentStream: sinon.stub().resolves(
createAsyncIterable([
{
responseId: "resp_1",
candidates: [
{
content: {
parts: [
{
functionCall: {
name: "read_file",
args: { path: ".nvmrc" },
},
},
],
},
},
],
},
{
responseId: "resp_1",
candidates: [
{
content: {
parts: [
{
functionCall: {
name: "read_file",
args: { path: ".gitattributes" },
},
},
],
},
},
],
},
]),
),
},
}
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
const tools = [{ name: "read_file", description: "read file", parameters: { type: "OBJECT" } }] as any
const chunks: any[] = []
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }], tools)) {
if (chunk.type === "tool_calls") {
chunks.push(chunk)
}
}
chunks.should.have.length(2)
chunks[0].tool_call.function.id.should.equal("resp_1-tool-0")
chunks[1].tool_call.function.id.should.equal("resp_1-tool-1")
chunks[0].tool_call.call_id.should.equal(chunks[0].tool_call.function.id)
chunks[1].tool_call.call_id.should.equal(chunks[1].tool_call.function.id)
JSON.parse(chunks[0].tool_call.function.arguments).path.should.equal(".nvmrc")
JSON.parse(chunks[1].tool_call.function.arguments).path.should.equal(".gitattributes")
})
it("should preserve Gemini-provided functionCall.id when present", async () => {
const handler = new GeminiHandler({
geminiApiKey: "test-api-key",
})
const fakeClient = {
models: {
generateContentStream: sinon.stub().resolves(
createAsyncIterable([
{
responseId: "resp_2",
candidates: [
{
content: {
parts: [
{
functionCall: {
id: "call_alpha",
name: "read_file",
args: { path: ".nvmrc" },
},
},
],
},
},
],
},
]),
),
},
}
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
const tools = [{ name: "read_file", description: "read file", parameters: { type: "OBJECT" } }] as any
const chunks: any[] = []
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }], tools)) {
if (chunk.type === "tool_calls") {
chunks.push(chunk)
}
}
chunks.should.have.length(1)
chunks[0].tool_call.function.id.should.equal("call_alpha")
chunks[0].tool_call.call_id.should.equal("call_alpha")
JSON.parse(chunks[0].tool_call.function.arguments).path.should.equal(".nvmrc")
})
})
@@ -1,43 +0,0 @@
import "should";
import {
huggingFaceDefaultModelId,
huggingFaceModels,
} from "../../../../shared/api";
import { HuggingFaceHandler } from "../huggingface";
describe("HuggingFaceHandler", () => {
it("uses dynamic Hugging Face model info for models outside the static list", () => {
const modelInfo = {
maxTokens: 8192,
contextWindow: 128_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Available on providers: test-provider",
};
const handler = new HuggingFaceHandler({
huggingFaceApiKey: "test-api-key",
huggingFaceModelId: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
huggingFaceModelInfo: modelInfo,
});
handler.getModel().should.deepEqual({
id: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
info: modelInfo,
});
});
it("preserves unknown model IDs when model info is unavailable", () => {
const handler = new HuggingFaceHandler({
huggingFaceApiKey: "test-api-key",
huggingFaceModelId: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
});
handler.getModel().should.deepEqual({
id: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
info: huggingFaceModels[huggingFaceDefaultModelId],
});
});
});
@@ -1,326 +0,0 @@
import { LiteLlmHandler, type LiteLlmModelInfoResponse } from "@core/api/providers/litellm"
import { convertToOpenAiMessages } from "@core/api/transform/openai-format"
import { liteLlmModelInfoSaneDefaults } from "@shared/api" // used in getModel tests
import { expect } from "chai"
import sinon from "sinon"
import { StateManager } from "@/core/storage/StateManager" // used in getModel tests
import { ClineStorageMessage } from "@/shared/messages/content"
import { mockFetchForTesting } from "@/shared/net"
const fakeClient = {
chat: {
completions: {
create: sinon.stub(),
},
},
baseURL: "https://fake.example",
}
describe("LiteLlmHandler", () => {
const mockFetch = sinon.stub()
let doneMockingFetch: (value: any) => void = () => {}
const mockModelFetch = (modelInfo: LiteLlmModelInfoResponse["data"][number]) => {
mockFetch.resolves({
ok: true,
json: () =>
Promise.resolve({
data: [modelInfo],
}),
})
}
let handler: LiteLlmHandler
const mockHandlerChat = () => {
sinon.stub(handler, "ensureClient" as any).returns(fakeClient)
}
const initializeHandler = (model: string) => {
handler = new LiteLlmHandler({
liteLlmApiKey: "test-api-key",
liteLlmBaseUrl: "http://localhost:4000",
liteLlmUsePromptCache: true,
liteLlmModelId: model,
})
mockHandlerChat()
}
beforeEach(() => {
fakeClient.chat.completions.create.resetHistory()
mockFetchForTesting(mockFetch, () => {
return new Promise((resolve) => {
doneMockingFetch = resolve
})
})
// Configure the stub to return a stream that closes immediately with usage data
fakeClient.chat.completions.create.resolves(
createAsyncIterable([
{
choices: [{ delta: { content: "test response" } }],
},
{
choices: [{}],
usage: {
prompt_tokens: 100,
completion_tokens: 50,
cache_creation_input_tokens: 20,
cache_read_input_tokens: 10,
},
},
]),
)
})
afterEach(() => {
sinon.reset()
doneMockingFetch(void 0)
})
const createAsyncIterable = (data: any[] = []) => {
return {
[Symbol.asyncIterator]: async function* () {
yield* data
},
}
}
describe("prompt cache", () => {
const setModelData = (model: string, supportsPromptCaching: boolean) => {
mockModelFetch({
model_name: model,
litellm_params: {
model,
},
model_info: {
supports_prompt_caching: supportsPromptCaching,
input_cost_per_token: 0.01,
output_cost_per_token: 0.02,
},
})
}
describe("when the model doesn't support prompt caching", () => {
const model = "openai/gpt-5"
beforeEach(() => {
initializeHandler(model)
setModelData(model, false)
})
it("sends the system prompt and messages with the openai format", async () => {
const systemPrompt = "Test System Prompt"
const messages: ClineStorageMessage[] = [
{
role: "user",
content: "first message",
},
{
role: "assistant",
content: "first response",
},
{
role: "user",
content: [
{
type: "text",
text: "test",
},
{
type: "text",
text: "second message",
},
],
},
]
for await (const _ of handler.createMessage(systemPrompt, messages)) {
}
sinon.assert.calledOnce(fakeClient.chat.completions.create)
const callArgs = fakeClient.chat.completions.create.getCall(0).args[0]
const systemPromptMessage = callArgs.messages.shift()
expect(systemPromptMessage).to.deep.equal({
role: "system",
content: systemPrompt,
})
expect(callArgs.messages).to.deep.equal(convertToOpenAiMessages(messages))
})
})
describe("when the model supports prompt caching", () => {
const model = "anthropic/claude-sonnet-4-20250514"
beforeEach(() => {
initializeHandler(model)
setModelData(model, true)
})
it("inserts the cache control in the system prompt and the last two user messages", async () => {
const systemPrompt = "Test System Prompt"
const messages: ClineStorageMessage[] = [
{
role: "user",
content: "first message",
},
{
role: "assistant",
content: "first response",
},
{
role: "user",
content: [
{
type: "text",
text: "test",
},
{
type: "text",
text: "second message",
},
],
},
]
for await (const _ of handler.createMessage(systemPrompt, messages)) {
}
sinon.assert.calledOnce(fakeClient.chat.completions.create)
const callArgs = fakeClient.chat.completions.create.getCall(0).args[0]
expect(callArgs.messages[0]).to.deep.equal({
role: "system",
content: [
{
text: systemPrompt,
type: "text",
cache_control: {
type: "ephemeral",
},
},
],
})
const sentMessages = callArgs.messages
expect(sentMessages.length).to.equal(4)
const firstUserMessage = sentMessages[1]
expect(firstUserMessage).to.deep.equal({
role: "user",
content: [
{
type: "text",
text: "first message",
cache_control: {
type: "ephemeral",
},
},
],
})
const lastUserMessage = sentMessages[3]
expect(lastUserMessage.content[0]).to.deep.equal({
type: "text",
text: "test",
})
const lastContentBlock = lastUserMessage.content[lastUserMessage.content.length - 1]
expect(lastContentBlock).to.deep.equal({
type: "text",
text: "second message",
cache_control: {
type: "ephemeral",
},
})
expect(callArgs.model).to.be.a("string")
expect(callArgs.stream).to.equal(true)
expect(callArgs.stream_options).to.deep.equal({ include_usage: true })
})
})
})
describe("getModel", () => {
let stateManagerStub: sinon.SinonStub
beforeEach(() => {
stateManagerStub = sinon.stub(StateManager, "get").returns({
getModelInfo: () => null,
} as any)
})
afterEach(() => {
stateManagerStub.restore()
})
it("returns sane defaults when no liteLlmModelInfo option is provided", () => {
const h = new LiteLlmHandler({
liteLlmApiKey: "test",
liteLlmModelId: "some-model",
})
const model = h.getModel()
expect(model.id).to.equal("some-model")
expect(model.info.contextWindow).to.equal(liteLlmModelInfoSaneDefaults.contextWindow)
})
it("returns user-configured model info when liteLlmModelInfo is provided and no cache exists", () => {
const h = new LiteLlmHandler({
liteLlmApiKey: "test",
liteLlmModelId: "claude-sonnet-4-6",
liteLlmModelInfo: {
contextWindow: 1_000_000,
maxTokens: 8192,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 3,
outputPrice: 15,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
},
})
const model = h.getModel()
expect(model.id).to.equal("claude-sonnet-4-6")
expect(model.info.contextWindow).to.equal(1_000_000)
})
it("prefers StateManager cached model info over user-configured liteLlmModelInfo", () => {
const cachedInfo = {
contextWindow: 200_000,
maxTokens: 4096,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 0,
outputPrice: 0,
}
stateManagerStub.returns({
getModelInfo: () => cachedInfo,
} as any)
const h = new LiteLlmHandler({
liteLlmApiKey: "test",
liteLlmModelId: "claude-sonnet-4-6",
liteLlmModelInfo: {
contextWindow: 1_000_000,
maxTokens: 8192,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 3,
outputPrice: 15,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
},
})
const model = h.getModel()
expect(model.info.contextWindow).to.equal(200_000)
})
})
})
@@ -1,53 +0,0 @@
import "should"
import { moonshotModels } from "@shared/api"
import type { ClineStorageMessage } from "@shared/messages/content"
import sinon from "sinon"
import { MoonshotHandler } from "../moonshot"
interface MoonshotRequestPayload {
model: string
temperature: number
max_tokens: number
}
describe("MoonshotHandler", () => {
afterEach(() => {
sinon.restore()
})
const createAsyncIterable = (data: unknown[] = []): AsyncIterable<unknown> => ({
[Symbol.asyncIterator]: async function* () {
yield* data
},
})
it("supports kimi-k2.6 model metadata", async () => {
const handler = new MoonshotHandler({
moonshotApiKey: "test-api-key",
apiModelId: "kimi-k2.6",
})
const model = handler.getModel()
model.id.should.equal("kimi-k2.6")
model.info.should.deepEqual(moonshotModels["kimi-k2.6"])
const createStub = sinon.stub().resolves(createAsyncIterable([]))
sinon.stub(handler as unknown as { ensureClient: () => unknown }, "ensureClient").returns({
chat: {
completions: {
create: createStub,
},
},
})
const messages: ClineStorageMessage[] = [{ role: "user", content: "hi" }]
for await (const _chunk of handler.createMessage("system", messages)) {
// Consume stream to trigger request execution.
}
const payload = createStub.firstCall.args[0] as MoonshotRequestPayload
payload.model.should.equal("kimi-k2.6")
payload.temperature.should.equal(moonshotModels["kimi-k2.6"].temperature)
payload.max_tokens.should.equal(moonshotModels["kimi-k2.6"].maxTokens)
})
})
@@ -1,93 +0,0 @@
import { expect } from "chai"
import { afterEach, describe, it } from "mocha"
import sinon from "sinon"
import { ClineStorageMessage } from "@/shared/messages/content"
import { ApiFormat } from "@/shared/proto/index.cline"
import { OcaHandler } from "../oca"
const messages: ClineStorageMessage[] = [{ role: "user", content: "Hello" }]
async function collectChunks(stream: AsyncGenerator<any>) {
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
return chunks
}
describe("OcaHandler.createMessage", () => {
afterEach(() => {
sinon.restore()
})
it("routes OPENAI_RESPONSES models to createMessageResponsesApi", async () => {
const handler = new OcaHandler({
ocaModelInfo: { apiFormat: ApiFormat.OPENAI_RESPONSES } as any,
})
const chatStub = sinon.stub(handler as any, "createMessageChatApi").callsFake(async function* () {
yield { type: "text", text: "chat" }
})
const responsesStub = sinon.stub(handler as any, "createMessageResponsesApi").callsFake(async function* () {
yield { type: "text", text: "responses" }
})
const messagesStub = sinon.stub(handler as any, "createMessageMessagesApi").callsFake(async function* () {
yield { type: "text", text: "messages" }
})
const chunks = await collectChunks(handler.createMessage("system", messages))
expect(chunks).to.deep.equal([{ type: "text", text: "responses" }])
sinon.assert.notCalled(chatStub)
sinon.assert.calledOnce(responsesStub)
sinon.assert.notCalled(messagesStub)
})
it("routes ANTHROPIC_CHAT models to createMessageMessagesApi", async () => {
const handler = new OcaHandler({
ocaModelInfo: { apiFormat: ApiFormat.ANTHROPIC_CHAT } as any,
})
const chatStub = sinon.stub(handler as any, "createMessageChatApi").callsFake(async function* () {
yield { type: "text", text: "chat" }
})
const responsesStub = sinon.stub(handler as any, "createMessageResponsesApi").callsFake(async function* () {
yield { type: "text", text: "responses" }
})
const messagesStub = sinon.stub(handler as any, "createMessageMessagesApi").callsFake(async function* () {
yield { type: "text", text: "messages" }
})
const chunks = await collectChunks(handler.createMessage("system", messages))
expect(chunks).to.deep.equal([{ type: "text", text: "messages" }])
sinon.assert.notCalled(chatStub)
sinon.assert.notCalled(responsesStub)
sinon.assert.calledOnce(messagesStub)
})
it("defaults to createMessageChatApi for OPENAI_CHAT and undefined apiFormat", async () => {
for (const apiFormat of [ApiFormat.OPENAI_CHAT, undefined]) {
const handler = new OcaHandler({
ocaModelInfo: { apiFormat } as any,
})
const chatStub = sinon.stub(handler as any, "createMessageChatApi").callsFake(async function* () {
yield { type: "text", text: "chat" }
})
const responsesStub = sinon.stub(handler as any, "createMessageResponsesApi").callsFake(async function* () {
yield { type: "text", text: "responses" }
})
const messagesStub = sinon.stub(handler as any, "createMessageMessagesApi").callsFake(async function* () {
yield { type: "text", text: "messages" }
})
const chunks = await collectChunks(handler.createMessage("system", messages))
expect(chunks).to.deep.equal([{ type: "text", text: "chat" }])
sinon.assert.calledOnce(chatStub)
sinon.assert.notCalled(responsesStub)
sinon.assert.notCalled(messagesStub)
}
})
})
@@ -1,231 +0,0 @@
import { afterEach, before, beforeEach, describe, it } from "mocha"
import "should"
import { ApiHandlerOptions } from "@shared/api"
import axios from "axios"
import sinon from "sinon"
import { ClineStorageMessage } from "@/shared/messages/content"
import { OllamaHandler } from "../ollama"
describe("OllamaHandler", () => {
let ollamaAvailable = false
// Check if Ollama is running before running tests
before(async function () {
this.timeout(5000)
try {
await axios.get("http://localhost:11434/api/version", { timeout: 2000 })
ollamaAvailable = true
} catch (_error) {
console.log("Ollama server not available, skipping tests")
ollamaAvailable = false
}
})
let handler: OllamaHandler
let options: ApiHandlerOptions
let clock: sinon.SinonFakeTimers
beforeEach(() => {
options = {
actModeOllamaModelId: "llama2",
ollamaBaseUrl: "http://localhost:11434",
}
handler = new OllamaHandler(options)
// Use fake timers for testing timeouts
clock = sinon.useFakeTimers()
})
afterEach(() => {
clock.restore()
sinon.restore()
})
describe("createMessage", () => {
it("should handle successful responses", async function () {
if (!ollamaAvailable) {
this.skip()
}
this.timeout(5000)
// Ensure client is initialized
const client = (handler as any).ensureClient()
// Mock the Ollama client's chat method
const chatStub = sinon.stub(client, "chat").resolves({
[Symbol.asyncIterator]: async function* () {
yield {
message: { content: "Hello, world!" },
eval_count: 10,
prompt_eval_count: 20,
}
},
} as any)
const systemPrompt = "You are a helpful assistant."
const messages: ClineStorageMessage[] = [{ role: "user", content: "Hello" }]
const result = []
const usageInfo = []
// Collect the results
for await (const chunk of handler.createMessage(systemPrompt, messages)) {
if (chunk.type === "text") {
result.push(chunk.text)
} else if (chunk.type === "usage") {
usageInfo.push({
inputTokens: chunk.inputTokens,
outputTokens: chunk.outputTokens,
})
}
}
// Verify the results
result.should.deepEqual(["Hello, world!"])
usageInfo.should.deepEqual([{ inputTokens: 20, outputTokens: 10 }])
chatStub.calledOnce.should.be.true()
})
it("should handle timeout errors", async function () {
if (!ollamaAvailable) {
this.skip()
}
this.timeout(10000)
// Restore real timers for this test
clock.restore()
// Create a handler with a very short timeout for testing
const testHandler = new OllamaHandler(options)
// Replace the createMessage method with one that has a shorter timeout
testHandler.createMessage = async function* (_systemPrompt, _messages) {
try {
// Create a promise that rejects after a short timeout
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("Ollama request timed out after 120 seconds")), 100)
})
// Create a promise that never resolves
const neverPromise = new Promise(() => {})
// Race them
await Promise.race([timeoutPromise, neverPromise])
} catch (error: any) {
// Enhance error reporting
console.error(`Ollama API error: ${error.message}`)
throw error
}
}
const systemPrompt = "You are a helpful assistant."
const messages: ClineStorageMessage[] = [{ role: "user", content: "Hello" }]
// Start the request and catch the error
let errorMessage = ""
try {
for await (const _ of testHandler.createMessage(systemPrompt, messages)) {
// This should not be reached
}
} catch (error: any) {
errorMessage = error.message
}
// Check the result
errorMessage.should.equal("Ollama request timed out after 120 seconds")
// Restore the fake timers for other tests
clock = sinon.useFakeTimers()
})
it("should retry on errors when using the withRetry decorator", async function () {
if (!ollamaAvailable) {
this.skip()
}
this.timeout(10000)
// Restore real timers for this test
clock.restore()
// Ensure client is initialized and mock the Ollama client's chat method to fail on first call and succeed on second
const client = (handler as any).ensureClient()
const chatStub = sinon.stub(client, "chat")
// First call throws an error
chatStub.onFirstCall().rejects(new Error("API Error"))
// Second call succeeds
chatStub.onSecondCall().resolves({
[Symbol.asyncIterator]: async function* () {
yield {
message: { content: "Success after retry" },
}
},
} as any)
const systemPrompt = "You are a helpful assistant."
const messages: ClineStorageMessage[] = [{ role: "user", content: "Hello" }]
const result = []
// Add a small delay to ensure the retry mechanism has time to work
await new Promise((resolve) => setTimeout(resolve, 100))
// Collect the results
for await (const chunk of handler.createMessage(systemPrompt, messages)) {
if (chunk.type === "text") {
result.push(chunk.text)
}
}
// Verify the results
result.should.deepEqual(["Success after retry"])
chatStub.calledTwice.should.be.true()
// Restore the fake timers for other tests
clock = sinon.useFakeTimers()
})
it("should handle stream processing errors", async function () {
if (!ollamaAvailable) {
this.skip()
}
this.timeout(10000)
// Restore real timers for this test
clock.restore()
// Create a handler with a custom implementation for testing
const testHandler = new OllamaHandler(options)
// Replace the createMessage method with one that simulates a stream error
testHandler.createMessage = async function* (_systemPrompt, _messages) {
// First yield a successful chunk
yield {
type: "text",
text: "Partial response",
}
// Then throw an error in the stream
throw new Error("Ollama stream processing error: Stream error")
}
const systemPrompt = "You are a helpful assistant."
const messages: ClineStorageMessage[] = [{ role: "user", content: "Hello" }]
const result = []
// Collect the results and catch the error
let errorMessage = ""
try {
for await (const chunk of testHandler.createMessage(systemPrompt, messages)) {
if (chunk.type === "text") {
result.push(chunk.text)
}
}
} catch (error: any) {
errorMessage = error.message
}
// Verify the results
errorMessage.should.equal("Ollama stream processing error: Stream error")
result.should.deepEqual(["Partial response"])
// Restore the fake timers for other tests
clock = sinon.useFakeTimers()
})
})
})
@@ -1,164 +0,0 @@
import "should"
import { openRouterDefaultModelInfo } from "@shared/api"
import sinon from "sinon"
import { OpenRouterHandler } from "../openrouter"
describe("OpenRouterHandler", () => {
afterEach(() => {
sinon.restore()
})
const createAsyncIterable = (data: any[] = []) => ({
[Symbol.asyncIterator]: async function* () {
yield* data
},
})
const tools = [{ type: "function", function: { name: "read_file", description: "", parameters: { type: "object" } } }] as any
it("should handle usage-only chunks when delta is missing", async () => {
const handler = new OpenRouterHandler({
openRouterApiKey: "test-api-key",
})
const fakeClient = {
chat: {
completions: {
create: sinon.stub().resolves(
createAsyncIterable([
{
choices: [{}],
usage: {
prompt_tokens: 13,
completion_tokens: 5,
},
},
]),
),
},
},
}
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
sinon.stub(handler, "getModel").returns({
id: "openai/gpt-4o-mini",
info: openRouterDefaultModelInfo,
})
const chunks: any[] = []
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
chunks.push(chunk)
}
chunks.should.deepEqual([
{
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: 0,
inputTokens: 13,
outputTokens: 5,
totalCost: 0,
},
])
})
it("should read cache_write_tokens from prompt_tokens_details", async () => {
const handler = new OpenRouterHandler({
openRouterApiKey: "test-api-key",
})
const fakeClient = {
chat: {
completions: {
create: sinon.stub().resolves(
createAsyncIterable([
{
choices: [{}],
usage: {
prompt_tokens: 1000,
completion_tokens: 200,
prompt_tokens_details: {
cached_tokens: 500,
cache_write_tokens: 300,
},
},
},
]),
),
},
},
}
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
sinon.stub(handler, "getModel").returns({
id: "anthropic/claude-sonnet-4.6",
info: openRouterDefaultModelInfo,
})
const chunks: any[] = []
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
chunks.push(chunk)
}
chunks.should.deepEqual([
{
type: "usage",
cacheWriteTokens: 300,
cacheReadTokens: 500,
inputTokens: 200,
outputTokens: 200,
totalCost: 0,
},
])
})
type ParallelToolCallsTestCase = {
modelId: string
enableParallelToolCalling: boolean
expectedParallelToolCalls: boolean
}
const parallelToolCallsTestCases: ParallelToolCallsTestCase[] = [
{
modelId: "openai/gpt-4o-mini",
enableParallelToolCalling: true,
expectedParallelToolCalls: true,
},
{
modelId: "openai/gpt-4o-mini",
enableParallelToolCalling: false,
expectedParallelToolCalls: false,
},
{
modelId: "google/gemini-3-flash-preview",
enableParallelToolCalling: true,
expectedParallelToolCalls: true,
},
]
for (const testCase of parallelToolCallsTestCases) {
const settingLabel = testCase.enableParallelToolCalling ? "enabled" : "disabled"
it(`should set parallel_tool_calls=${testCase.expectedParallelToolCalls} for ${testCase.modelId} when setting is ${settingLabel}`, async () => {
const handler = new OpenRouterHandler({
openRouterApiKey: "test-api-key",
enableParallelToolCalling: testCase.enableParallelToolCalling,
})
const createStub = sinon.stub().resolves(createAsyncIterable([]))
const fakeClient = {
chat: {
completions: {
create: createStub,
},
},
}
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
sinon.stub(handler, "getModel").returns({
id: testCase.modelId,
info: openRouterDefaultModelInfo,
})
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "hi" }], tools)) {
// drain stream
}
const payload = createStub.firstCall.args[0]
payload.parallel_tool_calls.should.equal(testCase.expectedParallelToolCalls)
})
}
})
@@ -1,132 +0,0 @@
import "should"
import { Anthropic } from "@anthropic-ai/sdk"
import { SapAiCoreHandler } from "../sapaicore"
describe("SapAiCoreHandler", () => {
let handler: SapAiCoreHandler
beforeEach(() => {
const mockOptions = {
sapAiCoreClientId: "test-client-id",
sapAiCoreClientSecret: "test-client-secret",
sapAiCoreTokenUrl: "https://test.auth.sap.com",
sapAiResourceGroup: "default",
sapAiCoreBaseUrl: "https://test.api.sap.com",
apiModelId: "anthropic--claude-3.5-sonnet",
}
handler = new SapAiCoreHandler(mockOptions)
})
describe("image processing", () => {
// Test image processing through the public interface
// This tests the complete flow including processImageContent internally
it("should handle image processing for Claude 4 models", () => {
// Create handler with Claude 4 model
const claude4Handler = new SapAiCoreHandler({
sapAiCoreClientId: "test-client-id",
sapAiCoreClientSecret: "test-client-secret",
sapAiCoreTokenUrl: "https://test.auth.sap.com",
sapAiResourceGroup: "default",
sapAiCoreBaseUrl: "https://test.api.sap.com",
apiModelId: "anthropic--claude-4-sonnet",
})
const model = claude4Handler.getModel()
model.id.should.equal("anthropic--claude-4-sonnet")
model.info.should.have.property("supportsImages", true)
})
it("should create proper user readable request with images", () => {
const testImageData =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
const userContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] = [
{
type: "text",
text: "Here's an image:",
},
{
type: "image",
source: {
type: "base64",
media_type: "image/png",
data: testImageData,
},
},
]
const result = handler.createUserReadableRequest(userContent)
result.should.have.property("model")
result.should.have.property("max_tokens")
result.should.have.property("system")
result.should.have.property("messages")
result.messages.should.be.Array()
result.messages[1].should.have.property("role", "user")
result.messages[1].should.have.property("content", userContent)
})
it("should support different Claude model variants", () => {
const modelVariants = [
"anthropic--claude-4.6-sonnet",
"anthropic--claude-4-sonnet",
"anthropic--claude-4-opus",
"anthropic--claude-3.7-sonnet",
"anthropic--claude-3.5-sonnet",
"anthropic--claude-3-sonnet",
"anthropic--claude-3-haiku",
"anthropic--claude-3-opus",
]
modelVariants.forEach((modelId) => {
const testHandler = new SapAiCoreHandler({
apiModelId: modelId,
})
const model = testHandler.getModel()
model.id.should.equal(modelId)
model.info.should.have.property("maxTokens")
model.info.should.have.property("contextWindow")
})
})
})
describe("getModel", () => {
it("should return default model when no apiModelId is provided", () => {
const result = handler.getModel()
result.should.have.property("id")
result.should.have.property("info")
result.info.should.have.property("maxTokens")
})
it("should return specified model when apiModelId is provided", () => {
const customHandler = new SapAiCoreHandler({
apiModelId: "anthropic--claude-4-sonnet",
})
const result = customHandler.getModel()
result.id.should.equal("anthropic--claude-4-sonnet")
})
})
describe("createUserReadableRequest", () => {
it("should create a readable request format", () => {
const userContent: Anthropic.TextBlockParam[] = [
{
type: "text",
text: "Hello, world!",
},
]
const result = handler.createUserReadableRequest(userContent)
result.should.have.property("model")
result.should.have.property("max_tokens")
result.should.have.property("system")
result.should.have.property("messages")
result.should.have.property("tools")
result.should.have.property("tool_choice")
})
})
})
@@ -1,138 +0,0 @@
import "should"
import { openRouterDefaultModelId, openRouterDefaultModelInfo } from "@shared/api"
import sinon from "sinon"
import { VercelAIGatewayHandler } from "../vercel-ai-gateway"
describe("VercelAIGatewayHandler", () => {
afterEach(() => {
sinon.restore()
})
const createAsyncIterable = (data: any[] = []) => ({
[Symbol.asyncIterator]: async function* () {
yield* data
},
})
describe("getModel", () => {
it("should return configured model and info when both are provided", () => {
const customModelInfo = {
...openRouterDefaultModelInfo,
maxTokens: 123456,
}
const handler = new VercelAIGatewayHandler({
openRouterModelId: "google/gemini-3.1-pro-preview",
openRouterModelInfo: customModelInfo,
})
const result = handler.getModel()
result.id.should.equal("google/gemini-3.1-pro-preview")
result.info.should.deepEqual(customModelInfo)
})
it("should preserve configured model ID when model info is missing", () => {
const handler = new VercelAIGatewayHandler({
openRouterModelId: "google/gemini-3.1-pro-preview",
})
const result = handler.getModel()
result.id.should.equal("google/gemini-3.1-pro-preview")
result.info.should.deepEqual(openRouterDefaultModelInfo)
})
it("should fall back to default model when model ID is missing", () => {
const handler = new VercelAIGatewayHandler({})
const result = handler.getModel()
result.id.should.equal(openRouterDefaultModelId)
result.info.should.deepEqual(openRouterDefaultModelInfo)
})
})
describe("createMessage", () => {
it("should handle usage-only chunks when delta is missing", async () => {
const handler = new VercelAIGatewayHandler({
vercelAiGatewayApiKey: "test-api-key",
})
const fakeClient = {
chat: {
completions: {
create: sinon.stub().resolves(
createAsyncIterable([
{
choices: [{}],
usage: {
prompt_tokens: 11,
completion_tokens: 7,
},
},
]),
),
},
},
}
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
const chunks: any[] = []
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
chunks.push(chunk)
}
chunks.should.deepEqual([
{
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: 0,
inputTokens: 11,
outputTokens: 7,
totalCost: 0,
},
])
})
it("should read Anthropic-style cache creation and read tokens from usage chunks", async () => {
const handler = new VercelAIGatewayHandler({
vercelAiGatewayApiKey: "test-api-key",
})
const fakeClient = {
chat: {
completions: {
create: sinon.stub().resolves(
createAsyncIterable([
{
choices: [{}],
usage: {
prompt_tokens: 1000,
completion_tokens: 200,
prompt_tokens_details: {
cached_tokens: 500,
},
cache_creation_input_tokens: 300,
},
},
]),
),
},
},
}
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
const chunks: any[] = []
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
chunks.push(chunk)
}
chunks.should.deepEqual([
{
type: "usage",
cacheWriteTokens: 300,
cacheReadTokens: 500,
inputTokens: 200,
outputTokens: 200,
totalCost: 0,
},
])
})
})
})
@@ -1,23 +0,0 @@
import "should"
import { vertexGlobalModels } from "@shared/api"
import { VertexHandler } from "../vertex"
describe("VertexHandler", () => {
it("supports Gemini 3.5 Flash model metadata", () => {
const handler = new VertexHandler({
vertexProjectId: "test-project",
vertexRegion: "global",
apiModelId: "gemini-3.5-flash",
})
const model = handler.getModel()
model.id.should.equal("gemini-3.5-flash")
model.info.contextWindow!.should.equal(1_048_576)
model.info.inputPrice!.should.equal(1.5)
model.info.outputPrice!.should.equal(9)
model.info.cacheReadsPrice!.should.equal(0.15)
model.info.supportsGlobalEndpoint!.should.equal(true)
model.info.supportsReasoning!.should.equal(true)
vertexGlobalModels.should.have.property("gemini-3.5-flash")
})
})
@@ -1,42 +0,0 @@
import "should"
import { openAiModelInfoSaneDefaults, wandbDefaultModelId, wandbModels } from "@shared/api"
import { WandbHandler } from "../wandb"
describe("WandbHandler", () => {
it("returns known catalog model metadata when model id is recognized", () => {
const modelId = "meta-llama/Llama-3.3-70B-Instruct"
const handler = new WandbHandler({
wandbApiKey: "test-api-key",
apiModelId: modelId,
})
const model = handler.getModel()
model.id.should.equal(modelId)
model.info.should.deepEqual(wandbModels[modelId])
})
it("passes through an explicit unknown model id instead of silently falling back", () => {
const unknownModelId = "moonshotai/Kimi-K2.5"
const handler = new WandbHandler({
wandbApiKey: "test-api-key",
apiModelId: unknownModelId,
})
const model = handler.getModel()
model.id.should.equal(unknownModelId)
model.info.should.deepEqual(openAiModelInfoSaneDefaults)
})
it("uses the default W&B model when no model id is configured", () => {
const handler = new WandbHandler({
wandbApiKey: "test-api-key",
})
const model = handler.getModel()
model.id.should.equal(wandbDefaultModelId)
model.info.should.deepEqual(wandbModels[wandbDefaultModelId])
})
})
@@ -1,327 +0,0 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { GenerateContentConfig, GoogleGenAI } from "@google/genai"
import { ModelInfo } from "@shared/api"
import OpenAI from "openai"
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
import { ApiHandler, CommonApiHandlerOptions } from "../index"
import { withRetry } from "../retry"
import { sanitizeAnthropicMessages } from "../transform/anthropic-format"
import { convertAnthropicMessageToGemini } from "../transform/gemini-format"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
interface AIhubmixHandlerOptions extends CommonApiHandlerOptions {
apiKey?: string
baseURL?: string
appCode?: string
modelId?: string
modelInfo?: ModelInfo
thinkingBudgetTokens?: number
}
export class AIhubmixHandler implements ApiHandler {
private options: AIhubmixHandlerOptions
private anthropicClient: Anthropic | undefined
private openaiClient: OpenAI | undefined
private geminiClient: GoogleGenAI | undefined
constructor(options: AIhubmixHandlerOptions) {
const { baseURL, appCode, ...rest } = options
this.options = {
baseURL: baseURL ?? "https://aihubmix.com",
appCode: appCode ?? "KUWF9311",
...rest,
}
}
private ensureAnthropicClient(): Anthropic {
if (!this.anthropicClient) {
if (!this.options.apiKey) {
throw new Error("AIhubmix API key is required")
}
try {
this.anthropicClient = new Anthropic({
apiKey: this.options.apiKey,
baseURL: this.options.baseURL,
defaultHeaders: {
"APP-Code": this.options.appCode,
...buildExternalBasicHeaders(),
},
})
} catch (error) {
throw new Error(`Error creating Anthropic client: ${error.message}`)
}
}
return this.anthropicClient
}
private ensureOpenaiClient(): OpenAI {
if (!this.openaiClient) {
if (!this.options.apiKey) {
throw new Error("AIhubmix API key is required")
}
try {
this.openaiClient = new OpenAI({
apiKey: this.options.apiKey,
baseURL: `${this.options.baseURL}/v1`,
defaultHeaders: {
"APP-Code": this.options.appCode,
...buildExternalBasicHeaders(),
},
})
} catch (error) {
throw new Error(`Error creating OpenAI client: ${error.message}`)
}
}
return this.openaiClient
}
private ensureGeminiClient(): GoogleGenAI {
if (!this.geminiClient) {
if (!this.options.apiKey) {
throw new Error("AIhubmix API key is required")
}
try {
this.geminiClient = new GoogleGenAI({
apiKey: this.options.apiKey,
httpOptions: {
baseUrl: `${this.options.baseURL}/gemini`,
headers: {
// @ts-expect-error
"APP-Code": this.options.appCode,
Authorization: `Bearer ${this.options.apiKey ?? ""}`,
...buildExternalBasicHeaders(),
},
},
})
} catch (error) {
throw new Error(`Error creating Gemini client: ${error.message}`)
}
}
return this.geminiClient
}
private routeModel(modelName: string): "anthropic" | "openai" | "gemini" | "openai-response" {
const id = modelName || ""
if (id.startsWith("claude")) {
return "anthropic"
}
if (id.startsWith("gemini") && !id.endsWith("-nothink") && !id.endsWith("-search")) {
return "gemini"
}
if (id === "gpt-5-pro" || id === "gpt-5-codex") {
return "openai-response"
}
return "openai"
}
private fixToolChoice(requestBody: any): any {
if (requestBody.tools?.length === 0 && requestBody.tool_choice) {
delete requestBody.tool_choice
}
return requestBody
}
@withRetry()
async *createMessage(systemPrompt: string, messages: any[]): ApiStream {
const modelId = this.options.modelId || ""
const route = this.routeModel(modelId)
switch (route) {
case "anthropic":
yield* this.createAnthropicMessage(systemPrompt, messages)
break
case "gemini":
yield* this.createGeminiMessage(systemPrompt, messages)
break
case "openai-response":
yield* this.createOpenaiResponseMessage(systemPrompt, messages)
break
case "openai":
yield* this.createOpenaiMessage(systemPrompt, messages)
break
default:
throw new Error(`Unsupported model route: ${route}`)
}
}
private async *createAnthropicMessage(systemPrompt: string, messages: any[]): ApiStream {
const client = this.ensureAnthropicClient()
const modelId = this.options.modelId || "claude-3-5-sonnet-20241022"
// Sanitize messages to remove Cline-specific fields like call_id that are not allowed by Anthropic API
const sanitizedMessages = sanitizeAnthropicMessages(messages, false)
const stream = await client.messages.create({
model: modelId,
temperature: 0,
max_tokens: this.options.modelInfo?.maxTokens || 8192,
system: [{ text: systemPrompt, type: "text" }],
messages: sanitizedMessages,
stream: true,
})
for await (const chunk of stream) {
switch (chunk?.type) {
case "message_start":
const usage = chunk.message.usage
yield {
type: "usage",
inputTokens: usage.input_tokens || 0,
outputTokens: usage.output_tokens || 0,
cacheWriteTokens: usage.cache_creation_input_tokens || undefined,
cacheReadTokens: usage.cache_read_input_tokens || undefined,
}
break
case "message_delta":
yield {
type: "usage",
inputTokens: 0,
outputTokens: chunk.usage.output_tokens || 0,
}
break
case "content_block_start":
if (chunk.content_block.type === "text") {
yield {
type: "text",
text: chunk.content_block.text,
}
}
break
case "content_block_delta":
if (chunk.delta.type === "text_delta") {
yield {
type: "text",
text: chunk.delta.text,
}
}
break
}
}
}
private async *createOpenaiResponseMessage(systemPrompt: string, messages: any[]): ApiStream {
const client = this.ensureOpenaiClient()
const modelId = this.options.modelId || "gpt-4o-mini"
const input = (messages || []).map((m: any) => {
const role = m.role || "user"
const contentArray = Array.isArray(m.content) ? m.content : [{ type: "text", text: m.content }]
const content = contentArray
.filter((c: any) => c != null)
.map((c: any) => {
if (c.type === "image" || c.type === "input_image" || c.type === "image_url") {
return { type: "input_image", image_url: c.image_url || c.url || c.source?.url }
}
const text = c.text ?? (typeof c === "string" ? c : "")
return { type: role === "assistant" ? "output_text" : "input_text", text }
})
return { role, content }
})
const stream = await (client as any).responses.stream({
model: modelId,
instructions: systemPrompt,
input,
})
for await (const event of stream as any) {
if (event?.type === "response.output_text.delta") {
yield { type: "text", text: event.delta || "" }
continue
}
if (event?.type === "response.completed") {
const usage = event.response?.usage || {}
yield {
type: "usage",
inputTokens: usage.input_tokens || 0,
outputTokens: usage.output_tokens || 0,
}
continue
}
if (event?.type === "response.error") {
throw new Error(event.error?.message || "responses error")
}
}
}
private async *createOpenaiMessage(systemPrompt: string, messages: any[]): ApiStream {
const client = this.ensureOpenaiClient()
const modelId = this.options.modelId || "gpt-4o-mini"
const openaiMessages = [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)]
const requestBody = {
model: modelId,
messages: openaiMessages,
temperature: 0,
stream: true,
}
const fixedRequestBody = this.fixToolChoice(requestBody)
const stream = await client.chat.completions.create(fixedRequestBody)
for await (const chunk of stream as any) {
const delta = chunk.choices?.[0]?.delta
if (delta?.content) {
yield {
type: "text",
text: delta.content,
}
}
if (chunk.usage) {
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
}
}
}
}
private async *createGeminiMessage(systemPrompt: string, messages: any[]): ApiStream {
const client = this.ensureGeminiClient()
const modelId = this.options.modelId || "gemini-2.0-flash-exp"
const contents = messages.map(convertAnthropicMessageToGemini)
const requestConfig: GenerateContentConfig = {
systemInstruction: systemPrompt,
temperature: 0,
}
if (this.options.thinkingBudgetTokens) {
requestConfig.thinkingConfig = {
thinkingBudget: this.options.thinkingBudgetTokens,
includeThoughts: true,
}
}
const stream = await client.models.generateContentStream({
model: modelId,
contents,
config: requestConfig,
})
for await (const chunk of stream as any) {
if (chunk?.text) {
yield { type: "text", text: chunk.text }
}
}
}
getModel(): { id: string; info: ModelInfo } {
return {
id: this.options.modelId || "gpt-4o-mini",
info: this.options.modelInfo || {
maxTokens: 8192,
contextWindow: 128000,
supportsImages: true,
supportsPromptCache: false,
description: "AIhubmix unified model provider",
},
}
}
}
@@ -1,316 +0,0 @@
import { Anthropic } from "@anthropic-ai/sdk"
import type {
MessageCreateParamsStreaming as BetaMessageCreateParamsStreaming,
BetaRawMessageStreamEvent,
} from "@anthropic-ai/sdk/resources/beta/messages/messages"
import { Tool as AnthropicTool } from "@anthropic-ai/sdk/resources/index"
import type { MessageCreateParamsStreaming as AnthropicMessageCreateParamsStreaming } from "@anthropic-ai/sdk/resources/messages/messages"
import { Stream as AnthropicStream } from "@anthropic-ai/sdk/streaming"
import {
ANTHROPIC_FAST_MODE_SUFFIX,
AnthropicModelId,
anthropicDefaultModelId,
anthropicModels,
CLAUDE_SONNET_1M_SUFFIX,
ModelInfo,
} from "@shared/api"
import { isClaudeOpusAdaptiveThinkingModel, resolveClaudeOpusAdaptiveThinking } from "@shared/utils/reasoning-support"
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
import { ClineStorageMessage } from "@/shared/messages/content"
import { fetch } from "@/shared/net"
import { ApiHandler, CommonApiHandlerOptions } from "../index"
import { withRetry } from "../retry"
import { sanitizeAnthropicMessages } from "../transform/anthropic-format"
import { ApiStream } from "../transform/stream"
export const ANTHROPIC_FAST_MODE_BETA = "fast-mode-2026-02-01"
interface AnthropicHandlerOptions extends CommonApiHandlerOptions {
apiKey?: string
anthropicBaseUrl?: string
apiModelId?: string
reasoningEffort?: string
thinkingBudgetTokens?: number
}
export class AnthropicHandler implements ApiHandler {
private options: AnthropicHandlerOptions
private client: Anthropic | undefined
constructor(options: AnthropicHandlerOptions) {
this.options = options
}
private ensureClient(): Anthropic {
if (!this.client) {
if (!this.options.apiKey) {
throw new Error("Anthropic API key is required")
}
try {
this.client = new Anthropic({
apiKey: this.options.apiKey,
baseURL: this.options.anthropicBaseUrl || undefined,
defaultHeaders: buildExternalBasicHeaders(),
fetch, // Use configured fetch with proxy support
})
} catch (error) {
throw new Error(`Error creating Anthropic client: ${error.message}`)
}
}
return this.client
}
@withRetry()
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: AnthropicTool[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
let stream: AnthropicStream<Anthropic.RawMessageStreamEvent> | AsyncIterable<BetaRawMessageStreamEvent>
const useFastMode = model.id.endsWith(ANTHROPIC_FAST_MODE_SUFFIX)
const baseModelId = useFastMode ? model.id.slice(0, -ANTHROPIC_FAST_MODE_SUFFIX.length) : model.id
const modelId = baseModelId.endsWith(CLAUDE_SONNET_1M_SUFFIX)
? baseModelId.slice(0, -CLAUDE_SONNET_1M_SUFFIX.length)
: baseModelId
const enable1mContextWindow = baseModelId.endsWith(CLAUDE_SONNET_1M_SUFFIX)
const fastModeBetas = enable1mContextWindow
? [ANTHROPIC_FAST_MODE_BETA, "context-1m-2025-08-07"]
: [ANTHROPIC_FAST_MODE_BETA]
const createFastModeMessage = (
body: AnthropicMessageCreateParamsStreaming,
): Promise<AsyncIterable<BetaRawMessageStreamEvent>> => {
return (
client.beta.messages.create as unknown as (
params: BetaMessageCreateParamsStreaming & { speed: "fast" },
) => Promise<AsyncIterable<BetaRawMessageStreamEvent>>
)({
...body,
betas: fastModeBetas,
speed: "fast",
})
}
const budget_tokens = this.options.thinkingBudgetTokens || 0
// Tools are available only when native tools are enabled.
const nativeToolsOn = tools?.length && tools?.length > 0
const reasoningOn = (model.info.supportsReasoning ?? false) && budget_tokens !== 0
// Claude Opus 4.5+ uses adaptive thinking instead of budgeted extended thinking.
const isAdaptiveThinkingModel = isClaudeOpusAdaptiveThinkingModel(modelId)
const adaptiveThinking = isAdaptiveThinkingModel
? resolveClaudeOpusAdaptiveThinking(this.options.reasoningEffort, budget_tokens)
: undefined
const adaptiveThinkingEnabled = adaptiveThinking?.enabled === true
const adaptiveThinkingEffort = adaptiveThinking?.effort
const thinkingEnabled = isAdaptiveThinkingModel ? adaptiveThinkingEnabled : reasoningOn
const thinkingConfig = thinkingEnabled
? isAdaptiveThinkingModel
? ({ type: "adaptive" } as any)
: { type: "enabled", budget_tokens: budget_tokens }
: undefined
const outputConfig = isAdaptiveThinkingModel && adaptiveThinkingEffort ? { effort: adaptiveThinkingEffort } : undefined
if (model.info.supportsPromptCache) {
const anthropicMessages = sanitizeAnthropicMessages(messages, true)
const requestBody: AnthropicMessageCreateParamsStreaming & Record<string, unknown> = {
model: modelId,
thinking: thinkingConfig,
max_tokens: model.info.maxTokens || 8192,
// "Thinking isn't compatible with temperature, top_p, or top_k modifications as well as forced tool use."
// (https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking)
// Adaptive Claude Opus models do not support temperature.
temperature: isAdaptiveThinkingModel ? undefined : reasoningOn ? undefined : 0,
system: [
{
text: systemPrompt,
type: "text",
cache_control: { type: "ephemeral" },
},
], // setting cache breakpoint for system prompt so new tasks can reuse it
messages: anthropicMessages,
// tools, // cache breakpoints go from tools > system > messages, and since tools dont change, we can just set the breakpoint at the end of system (this avoids having to set a breakpoint at the end of tools which by itself does not meet min requirements for haiku caching)
stream: true,
tools: nativeToolsOn ? tools : undefined,
// tool_choice options:
// - none: disables tool use, even if tools are provided. Claude will not call any tools.
// - auto: allows Claude to decide whether to call any provided tools or not. This is the default value when tools are provided.
// - any: tells Claude that it must use one of the provided tools, but doesnt force a particular tool.
// NOTE: Forcing tool use when tools are provided will result in error when thinking is also enabled.
tool_choice: nativeToolsOn && !thinkingEnabled ? { type: "any" } : undefined,
}
if (outputConfig) {
requestBody.output_config = outputConfig
}
stream = useFastMode
? await createFastModeMessage(requestBody)
: await client.messages.create(
requestBody,
(() => {
// 1m context window beta header
if (enable1mContextWindow) {
return {
headers: {
"anthropic-beta": "context-1m-2025-08-07",
},
}
}
return undefined
})(),
)
} else {
const requestBody: AnthropicMessageCreateParamsStreaming & Record<string, unknown> = {
model: modelId,
max_tokens: model.info.maxTokens || 8192,
temperature: isAdaptiveThinkingModel ? undefined : reasoningOn ? undefined : 0,
system: [{ text: systemPrompt, type: "text" }],
messages: sanitizeAnthropicMessages(messages, false),
tools: nativeToolsOn ? tools : undefined,
tool_choice: thinkingEnabled ? undefined : { type: "auto" },
stream: true,
thinking: thinkingConfig,
}
if (outputConfig) {
requestBody.output_config = outputConfig
}
stream = useFastMode ? await createFastModeMessage(requestBody) : await client.messages.create(requestBody)
}
const lastStartedToolCall = { id: "", name: "", arguments: "" }
for await (const chunk of stream) {
switch (chunk?.type) {
case "message_start":
{
// tells us cache reads/writes/input/output
const usage = chunk.message.usage
yield {
type: "usage",
inputTokens: usage.input_tokens || 0,
outputTokens: usage.output_tokens || 0,
cacheWriteTokens: usage.cache_creation_input_tokens || undefined,
cacheReadTokens: usage.cache_read_input_tokens || undefined,
}
}
break
case "message_delta":
// tells us stop_reason, stop_sequence, and output tokens along the way and at the end of the message
yield {
type: "usage",
inputTokens: 0,
outputTokens: chunk.usage.output_tokens || 0,
}
break
case "message_stop":
// no usage data, just an indicator that the message is done
break
case "content_block_start":
switch (chunk.content_block.type) {
case "thinking":
yield {
type: "reasoning",
reasoning: chunk.content_block.thinking || "",
signature: chunk.content_block.signature,
}
break
case "redacted_thinking":
// Content is encrypted, and we don't to pass placeholder text back to the API
yield {
type: "reasoning",
reasoning: "[Redacted thinking block]",
redacted_data: chunk.content_block.data,
}
break
case "tool_use":
if (chunk.content_block.id && chunk.content_block.name) {
// Convert Anthropic tool_use to OpenAI-compatible format
lastStartedToolCall.id = chunk.content_block.id
lastStartedToolCall.name = chunk.content_block.name
lastStartedToolCall.arguments = ""
}
break
case "text":
// we may receive multiple text blocks, in which case just insert a line break between them
if (chunk.index > 0) {
yield {
type: "text",
text: "\n",
}
}
yield {
type: "text",
text: chunk.content_block.text,
}
break
}
break
case "content_block_delta":
switch (chunk.delta.type) {
case "thinking_delta":
// 'reasoning' type just displays in the UI, but ant_thinking will be used to send the thinking traces back to the API
yield {
type: "reasoning",
reasoning: chunk.delta.thinking,
}
break
case "signature_delta":
// It's used when sending the thinking block back to the API
// API expects this in completed form, not as array of deltas
if (chunk.delta.signature) {
yield {
type: "reasoning",
reasoning: "", // reasoning text is already sent via thinking_delta
signature: chunk.delta.signature,
}
}
break
case "text_delta":
yield {
type: "text",
text: chunk.delta.text,
}
break
case "input_json_delta":
if (lastStartedToolCall.id && lastStartedToolCall.name && chunk.delta.partial_json) {
// // Convert Anthropic tool_use to OpenAI-compatible format
yield {
type: "tool_calls",
tool_call: {
...lastStartedToolCall,
function: {
...lastStartedToolCall,
id: lastStartedToolCall.id,
name: lastStartedToolCall.name,
arguments: chunk.delta.partial_json,
},
},
}
}
break
}
break
case "content_block_stop":
lastStartedToolCall.id = ""
lastStartedToolCall.name = ""
lastStartedToolCall.arguments = ""
break
}
}
}
getModel(): { id: AnthropicModelId; info: ModelInfo } {
const modelId = this.options.apiModelId
if (modelId && modelId in anthropicModels) {
const id = modelId as AnthropicModelId
return { id, info: anthropicModels[id] }
}
return {
id: anthropicDefaultModelId,
info: anthropicModels[anthropicDefaultModelId],
}
}
}
@@ -1,200 +0,0 @@
import { AskSageModelId, askSageDefaultModelId, askSageDefaultURL, askSageModels, ModelInfo } from "@shared/api"
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
import { ClineStorageMessage } from "@/shared/messages/content"
import { fetch } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
import { ApiHandler, CommonApiHandlerOptions } from ".."
import { withRetry } from "../retry"
import { ApiStream } from "../transform/stream"
interface AskSageHandlerOptions extends CommonApiHandlerOptions {
asksageApiKey?: string
asksageApiUrl?: string
apiModelId?: string
}
type AskSageRequest = {
system_prompt: string
message: {
user: "gpt" | "me"
message: string
}[]
model: string
dataset: "none"
usage: boolean
}
type AskSageUsage = {
model_tokens: {
completion_tokens: number
prompt_tokens: number
total_tokens: number
}
asksage_tokens: number
}
type AskSageResponse = {
uuid: string
status: number
// Response status
response: string
// Generated response message
message: string
// whether embedding & vector systems are down
embedding_down: boolean
vectors_down: boolean
// references if dataset is not none
references: string
type: string
added_obj: any
tool_calls: any
// usage metrics
usage: AskSageUsage | null
tool_responses: any[]
tool_calls_unified: any[]
}
export class AskSageHandler implements ApiHandler {
private options: AskSageHandlerOptions
private apiUrl: string
private apiKey: string
constructor(options: AskSageHandlerOptions) {
Logger.log("init api url", options.asksageApiUrl, askSageDefaultURL)
this.options = options
this.apiKey = options.asksageApiKey || ""
this.apiUrl = options.asksageApiUrl || askSageDefaultURL
if (!this.apiKey) {
throw new Error("AskSage API key is required")
}
}
@withRetry()
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[]): ApiStream {
try {
const model = this.getModel()
// Transform messages into AskSageRequest format
const formattedMessages = messages.map((msg) => {
const content = Array.isArray(msg.content)
? msg.content.map((block) => ("text" in block ? block.text : "")).join("")
: msg.content
return {
user: msg.role === "assistant" ? ("gpt" as const) : ("me" as const),
message: content,
}
})
const request: AskSageRequest = {
system_prompt: systemPrompt,
message: formattedMessages,
model: model.id,
dataset: "none",
usage: true,
}
// Make request to AskSage API
const response = await fetch(`${this.apiUrl}/query`, {
method: "POST",
headers: this.headers(),
body: JSON.stringify(request),
})
if (!response.ok) {
const error = await response.text()
throw new Error(`AskSage API error: ${error}`)
}
const result = (await response.json()) as AskSageResponse
if (!result.message) {
throw new Error("No content in AskSage response")
}
// Yield tool responses if they exist
if (result.tool_responses && result.tool_responses.length > 0) {
for (const toolResponse of result.tool_responses) {
yield {
type: "text",
text: `[Tool Response: ${JSON.stringify(toolResponse)}]\n`,
}
}
}
// Yield the main response text
yield {
type: "text",
text: result.message,
}
// Yield usage information if available
if (result.usage) {
yield {
type: "usage",
inputTokens: result.usage.model_tokens.prompt_tokens,
outputTokens: result.usage.model_tokens.completion_tokens,
cacheReadTokens: 0,
cacheWriteTokens: 0,
totalCost: result.usage.asksage_tokens, // Cost = Consumed AskSage tokens
}
}
} catch (error) {
if (error instanceof Error) {
throw new Error(`AskSage request failed: ${error.message}`)
}
throw error
}
}
async getApiStreamUsage() {
if (!this.apiKey) {
return undefined
}
try {
const response = await fetch(`${this.apiUrl}/count-monthly-tokens`, {
method: "POST",
headers: this.headers(),
body: JSON.stringify({ app_name: "asksage" }),
})
if (!response.ok) {
Logger.error("Failed to fetch AskSage usage", await response.text())
return undefined
}
const data = await response.json()
const usedTokens = data.response as number
return {
type: "usage" as const,
inputTokens: usedTokens,
outputTokens: 0,
}
} catch (error) {
Logger.error("Error fetching AskSage usage:", error)
return undefined
}
}
getModel(): { id: string; info: ModelInfo } {
const modelId = this.options.apiModelId
if (modelId && modelId in askSageModels) {
const id = modelId as AskSageModelId
return { id, info: askSageModels[id] }
}
return {
id: askSageDefaultModelId,
info: askSageModels[askSageDefaultModelId],
}
}
private headers() {
return {
"Content-Type": "application/json",
"x-access-tokens": this.apiKey,
...buildExternalBasicHeaders(),
}
}
}
@@ -1,172 +0,0 @@
import { BasetenModelId, basetenDefaultModelId, basetenModels, ModelInfo } from "@shared/api"
import { calculateApiCostOpenAI } from "@utils/cost"
import OpenAI from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
import { ClineStorageMessage } from "@/shared/messages/content"
import { fetch } from "@/shared/net"
import { ApiHandler, CommonApiHandlerOptions } from "../"
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import { ToolCallProcessor } from "../transform/tool-call-processor"
interface BasetenHandlerOptions extends CommonApiHandlerOptions {
basetenApiKey?: string
basetenModelId?: string
basetenModelInfo?: ModelInfo
apiModelId?: string // For backward compatibility
}
export class BasetenHandler implements ApiHandler {
private options: BasetenHandlerOptions
private client: OpenAI | undefined
constructor(options: BasetenHandlerOptions) {
this.options = options
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.basetenApiKey) {
throw new Error("Baseten API key is required")
}
try {
this.client = new OpenAI({
baseURL: "https://inference.baseten.co/v1",
apiKey: this.options.basetenApiKey,
defaultHeaders: buildExternalBasicHeaders(),
fetch, // Use configured fetch with proxy support
})
} catch (error) {
throw new Error(`Error creating Baseten client: ${error.message}`)
}
}
return this.client
}
/**
* Gets the optimal max_tokens based on model capabilities
*/
private getOptimalMaxTokens(model: { id: BasetenModelId; info: ModelInfo }): number {
// Use model-specific max tokens if available
if (model.info.maxTokens && model.info.maxTokens > 0) {
return model.info.maxTokens
}
// Default fallback
return 8192
}
getModel(): { id: BasetenModelId; info: ModelInfo } {
// First priority: basetenModelId and basetenModelInfo
const basetenModelId = this.options.basetenModelId
const basetenModelInfo = this.options.basetenModelInfo
if (basetenModelId && basetenModelInfo) {
return { id: basetenModelId as BasetenModelId, info: basetenModelInfo }
}
// Second priority: basetenModelId with static model info
if (basetenModelId && basetenModelId in basetenModels) {
const id = basetenModelId as BasetenModelId
return { id, info: basetenModels[id] }
}
// Third priority: apiModelId (for backward compatibility)
const apiModelId = this.options.apiModelId
if (apiModelId && apiModelId in basetenModels) {
const id = apiModelId as BasetenModelId
return { id, info: basetenModels[id] }
}
// Default fallback
return {
id: basetenDefaultModelId,
info: basetenModels[basetenDefaultModelId],
}
}
private async *yieldUsage(modelInfo: ModelInfo, usage: any): ApiStream {
if (usage.prompt_tokens || usage.completion_tokens) {
const cost = calculateApiCostOpenAI(modelInfo, usage.prompt_tokens || 0, usage.completion_tokens || 0)
yield {
type: "usage",
inputTokens: usage.prompt_tokens || 0,
outputTokens: usage.completion_tokens || 0,
cacheWriteTokens: 0,
cacheReadTokens: 0,
totalCost: cost,
}
}
}
@withRetry()
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: OpenAITool[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
const maxTokens = this.getOptimalMaxTokens(model)
const toolCallProcessor = new ToolCallProcessor()
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
const stream = await client.chat.completions.create({
model: model.id,
max_tokens: maxTokens,
messages: openAiMessages,
stream: true,
stream_options: { include_usage: true },
temperature: 0,
tools,
tool_choice: tools && tools.length > 0 ? "auto" : undefined,
})
let didOutputUsage = false
for await (const chunk of stream) {
const delta = chunk?.choices?.[0]?.delta
// Handle reasoning field if present (for reasoning models with parsed output)
if (delta && "reasoning" in delta && delta?.reasoning) {
const reasoning = typeof delta.reasoning === "string" ? delta.reasoning : JSON.stringify(delta.reasoning)
yield {
type: "reasoning",
reasoning,
}
}
// Handle content field
if (delta?.content) {
yield {
type: "text",
text: delta.content,
}
}
if (delta?.tool_calls) {
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
}
// Handle usage information - only output once
if (!didOutputUsage && chunk.usage) {
yield* this.yieldUsage(model.info, chunk.usage)
didOutputUsage = true
}
}
}
/**
* Checks if the current model supports tools
*/
supportsTools(): boolean {
const model = this.getModel()
const modelInfo = model.info as any
// Use dynamic API data when available, fallback to true since all current Baseten models support tools
// (as of 2025-09-16 - could change if Baseten add non-tool models in future, currently no plans to do so)
return modelInfo.supportedFeatures ? modelInfo.supportedFeatures.includes("tools") : true
}
}
File diff suppressed because it is too large Load Diff
@@ -1,275 +0,0 @@
import Cerebras from "@cerebras/cerebras_cloud_sdk"
import { CerebrasModelId, cerebrasDefaultModelId, cerebrasModels, ModelInfo } from "@shared/api"
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
import { ClineStorageMessage } from "@/shared/messages/content"
import { fetch } from "@/shared/net"
import { ApiHandler, CommonApiHandlerOptions } from "../index"
import { withRetry } from "../retry"
import { ApiStream } from "../transform/stream"
interface CerebrasHandlerOptions extends CommonApiHandlerOptions {
cerebrasApiKey?: string
apiModelId?: string
}
// Conservative max_tokens for Cerebras to avoid premature rate limiting.
// Cerebras rate limiter estimates token consumption using max_completion_tokens upfront,
// so requesting the model maximum (e.g., 64K) reserves that quota even if actual usage is low.
// 16K is sufficient for most agentic tool use while preserving rate limit headroom.
const CEREBRAS_DEFAULT_MAX_TOKENS = 16_384
export class CerebrasHandler implements ApiHandler {
private options: CerebrasHandlerOptions
private client: Cerebras | undefined
constructor(options: CerebrasHandlerOptions) {
this.options = options
}
private ensureClient(): Cerebras {
if (!this.client) {
// Clean and validate the API key
const cleanApiKey = this.options.cerebrasApiKey?.trim()
if (!cleanApiKey) {
throw new Error("Cerebras API key is required")
}
try {
const externalHeaders = buildExternalBasicHeaders()
this.client = new Cerebras({
apiKey: cleanApiKey,
timeout: 30000, // 30 second timeout
fetch, // Use configured fetch with proxy support
defaultHeaders: {
...externalHeaders,
"X-Cerebras-3rd-Party-Integration": "cline",
},
})
} catch (error) {
throw new Error(`Error creating Cerebras client: ${error.message}`)
}
}
return this.client
}
@withRetry({
maxRetries: 6, // More retries to be patient with rate limits
baseDelay: 5000, // Start with 5 second delay
maxDelay: 60000, // Allow up to 60 second delays to respect rate limits
})
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[]): ApiStream {
const client = this.ensureClient()
// Convert Anthropic messages to Cerebras format
const cerebrasMessages: Array<{
role: "system" | "user" | "assistant"
content: string
}> = [{ role: "system", content: systemPrompt }]
// Helper function to strip thinking tags from content
const stripThinkingTags = (content: string): string => {
return content.replace(/<think>[\s\S]*?<\/think>/g, "").trim()
}
// Check if this is a reasoning model that uses thinking tags
const modelId = this.getModel().id
const isReasoningModel = modelId.includes("qwen")
// Convert Anthropic messages to Cerebras format
for (const message of messages) {
if (message.role === "user") {
const content = Array.isArray(message.content)
? message.content
.map((block) => {
if (block.type === "text") {
return block.text
}
if (block.type === "image") {
return "[Image content not supported in Cerebras]"
}
return ""
})
.join("\n")
: message.content
cerebrasMessages.push({ role: "user", content })
} else if (message.role === "assistant") {
let content = Array.isArray(message.content)
? message.content
.map((block) => {
if (block.type === "text") {
return block.text
}
return ""
})
.join("\n")
: message.content || ""
// Strip thinking tags from assistant messages for reasoning models
// so the model doesn't see its own thinking in the conversation history
if (isReasoningModel) {
content = stripThinkingTags(content)
}
cerebrasMessages.push({ role: "assistant", content })
}
}
try {
const model = this.getModel()
const stream = await client.chat.completions.create({
model: model.id,
messages: cerebrasMessages,
temperature: model.info.temperature ?? 0,
stream: true,
max_tokens: CEREBRAS_DEFAULT_MAX_TOKENS,
})
// Handle streaming response
let reasoning: string | null = null // Track reasoning content for models that support thinking
for await (const chunk of stream as any) {
// Type assertion for the streaming chunk
const streamChunk = chunk as any
if (streamChunk.choices?.[0]?.delta?.content) {
const content = streamChunk.choices[0].delta.content
// Handle reasoning models (Qwen and DeepSeek R1 Distill) that use <think> tags
if (isReasoningModel) {
// Check if we're entering or continuing reasoning mode
if (reasoning || content.includes("<think>")) {
reasoning = (reasoning || "") + content
// Clean the content by removing think tags for display
const cleanContent = content.replace(/<think>/g, "").replace(/<\/think>/g, "")
// Only yield reasoning content if there's actual content after cleaning
if (cleanContent.trim()) {
yield {
type: "reasoning",
reasoning: cleanContent,
}
}
// Check if reasoning is complete
if (reasoning.includes("</think>")) {
reasoning = null
}
} else {
// Regular content outside of thinking tags
yield {
type: "text",
text: content,
}
}
} else {
// Non-reasoning models - just yield text content
yield {
type: "text",
text: content,
}
}
}
// Handle usage information from Cerebras API
// Usage is typically only available in the final chunk
if (streamChunk.usage) {
const totalCost = this.calculateCost({
inputTokens: streamChunk.usage.prompt_tokens || 0,
outputTokens: streamChunk.usage.completion_tokens || 0,
})
yield {
type: "usage",
inputTokens: streamChunk.usage.prompt_tokens || 0,
outputTokens: streamChunk.usage.completion_tokens || 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
totalCost,
}
}
}
} catch (error: any) {
// Enhanced error handling for Cerebras API
if (error?.status === 429 || error?.code === "rate_limit_exceeded") {
// Rate limit error - will be handled by retry decorator with patient backoff
const _limits = this.getRateLimits()
throw new Error(`Cerebras API rate limit exceeded.`)
}
if (error?.status === 401) {
throw new Error("Cerebras API authentication failed. Please check your API key.")
}
if (error?.status === 403) {
throw new Error("Cerebras API access denied. Please check your API key permissions.")
}
if (error?.status >= 500) {
// Server errors - retryable
throw new Error(`Cerebras API server error (${error.status}): ${error.message || "Unknown server error"}`)
}
if (error?.status === 400) {
// Client errors - not retryable
throw new Error(`Cerebras API bad request: ${error.message || "Invalid request parameters"}`)
}
// Re-throw original error for other cases
throw error
}
}
getModel(): { id: string; info: ModelInfo } {
const originalModelId = this.options.apiModelId
let apiModelId = originalModelId
if (originalModelId === "qwen-3-coder-480b-free") {
apiModelId = "qwen-3-coder-480b"
return { id: apiModelId, info: cerebrasModels[originalModelId as CerebrasModelId] }
}
if (originalModelId && originalModelId in cerebrasModels) {
const id = originalModelId as CerebrasModelId
return { id, info: cerebrasModels[id] }
}
return {
id: cerebrasDefaultModelId,
info: cerebrasModels[cerebrasDefaultModelId],
}
}
/**
* Get rate limit information for the current model
*
* These limits are used for informational purposes and to calculate appropriate
* retry delays. Since Cerebras inference is extremely fast, users hit these limits
* quickly, so we need to be patient with retries to maximize usage efficiency.
*
* @returns Rate limit configuration for the model
*/
private getRateLimits(): { requestsPerMinute: number; tokensPerMinute: number } {
const modelId = this.getModel().id
switch (modelId) {
case "qwen-3-coder-480b":
case "qwen-3-coder-480b-free":
return { requestsPerMinute: 10, tokensPerMinute: 150_000 }
case "qwen-3-235b-a22b-instruct-2507":
case "qwen-3-235b-a22b-thinking-2507":
return { requestsPerMinute: 30, tokensPerMinute: 60_000 }
case "gpt-oss-120b":
return { requestsPerMinute: 30, tokensPerMinute: 64_000 }
default:
// Default rate limits for unknown models
return { requestsPerMinute: 30, tokensPerMinute: 60_000 }
}
}
private calculateCost({ inputTokens, outputTokens }: { inputTokens: number; outputTokens: number }): number {
const model = this.getModel()
const inputPrice = model.info.inputPrice || 0
const outputPrice = model.info.outputPrice || 0
const inputCost = (inputPrice / 1_000_000) * inputTokens
const outputCost = (outputPrice / 1_000_000) * outputTokens
return inputCost + outputCost
}
}
@@ -1,229 +0,0 @@
import { filterMessagesForClaudeCode } from "@/integrations/claude-code/message-filter"
import { runClaudeCode } from "@/integrations/claude-code/run"
import { ClaudeCodeModelId, claudeCodeDefaultModelId, claudeCodeModels } from "@/shared/api"
import { ClineStorageMessage } from "@/shared/messages/content"
import { Logger } from "@/shared/services/Logger"
import { type ApiHandler, CommonApiHandlerOptions } from ".."
import { withRetry } from "../retry"
import { type ApiStream, ApiStreamUsageChunk } from "../transform/stream"
interface ClaudeCodeHandlerOptions extends CommonApiHandlerOptions {
claudeCodePath?: string
apiModelId?: string
thinkingBudgetTokens?: number
}
export class ClaudeCodeHandler implements ApiHandler {
private options: ClaudeCodeHandlerOptions
constructor(options: ClaudeCodeHandlerOptions) {
this.options = options
}
@withRetry({
maxRetries: 4,
baseDelay: 2000,
maxDelay: 15000,
})
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[]): ApiStream {
// Filter out image blocks since Claude Code doesn't support them
const filteredMessages = filterMessagesForClaudeCode(messages)
const claudeProcess = runClaudeCode({
systemPrompt,
messages: filteredMessages,
path: this.options.claudeCodePath,
modelId: this.getModel().id,
thinkingBudgetTokens: this.options.thinkingBudgetTokens,
})
// Usage is included with assistant messages,
// but cost is included in the result chunk
const usage: ApiStreamUsageChunk = {
type: "usage",
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
}
let isPaidUsage = true
for await (const chunk of claudeProcess) {
if (typeof chunk === "string") {
yield {
type: "text",
text: chunk,
}
continue
}
// Handle system init messages
if (chunk.type === "system" && "subtype" in chunk) {
if (chunk.subtype === "init") {
// Based on my tests, subscription usage sets the `apiKeySource` to "none"
isPaidUsage = (chunk as any).apiKeySource !== "none"
}
// Also handles legacy rate_limit_event format (type: "system", subtype: "rate_limit_event")
// by falling through — no special handling needed.
continue
}
// Handle rate_limit_event (newer CLI format: top-level type)
if (chunk.type === "rate_limit_event") {
// Rate limit events are informational. Log them but don't yield anything.
// If the rate limit blocks the response, the stream will end without
// assistant messages and the task loop will handle the empty response.
Logger.log("Claude Code rate limit event:", JSON.stringify(chunk))
continue
}
// Skip user messages (tool results from Claude Code's own tool execution)
if (chunk.type === "user") {
continue
}
if (chunk.type === "assistant" && "message" in chunk) {
const message = chunk.message
// Check for error field on the message (newer CLI format)
if (message.error) {
const firstContent = message.content?.[0]
const errorText = firstContent && "text" in firstContent ? firstContent.text : undefined
throw new Error(errorText ?? `Claude Code error: ${message.error}`)
}
if (message.stop_reason !== null) {
const firstContent = message.content?.[0]
const content = firstContent && "text" in firstContent ? firstContent : undefined
// Check if content exists before accessing its properties
if (content && content.text.startsWith(`API Error`)) {
// Error messages are formatted as: `API Error: <<status code>> <<json>>`
const errorMessageStart = content.text.indexOf("{")
const errorMessage = content.text.slice(errorMessageStart)
const error = this.attemptParse(errorMessage)
if (!error) {
throw new Error(content.text)
}
if (error.error.message.includes("Invalid model name")) {
throw new Error(
content.text +
`\n\nAPI keys and subscription plans allow different models. Make sure the selected model is included in your plan.`,
)
}
throw new Error(errorMessage)
}
}
for (const content of message.content) {
switch (content.type) {
case "text":
yield {
type: "text",
text: content.text,
}
break
case "thinking":
yield {
type: "reasoning",
reasoning: content.thinking || "",
}
break
case "redacted_thinking":
yield {
type: "reasoning",
reasoning: "[Redacted thinking block]",
}
break
case "tool_use":
// Yield tool_use blocks to the streaming pipeline for proper tool execution
yield {
type: "tool_calls",
tool_call: {
call_id: content.id,
function: {
id: content.id,
name: content.name,
arguments: JSON.stringify(content.input),
},
},
}
break
default: {
// Handle unknown content block types gracefully.
// Newer Anthropic models or CLI versions may introduce new content types
// (e.g., server_tool_use, mcp_tool_use). Log them instead of silently dropping.
const unknownBlock = content as { type: string; text?: string }
Logger.warn(`Unhandled content type in Claude Code response: ${unknownBlock.type}`)
// If the unknown block has a text-like field, try to yield it as text
if (typeof unknownBlock.text === "string") {
yield {
type: "text",
text: unknownBlock.text,
}
}
break
}
}
}
// According to Anthropic's API documentation:
// https://docs.anthropic.com/en/api/messages#usage-object
// The `input_tokens` field already includes both `cache_read_input_tokens` and `cache_creation_input_tokens`.
// Therefore, we should not add cache tokens to the input_tokens count again, as this would result in double-counting.
usage.inputTokens = message.usage?.input_tokens ?? 0
usage.outputTokens = message.usage?.output_tokens ?? 0
usage.cacheReadTokens = message.usage?.cache_read_input_tokens ?? 0
usage.cacheWriteTokens = message.usage?.cache_creation_input_tokens ?? 0
continue
}
if (chunk.type === "result" && "result" in chunk) {
if (chunk.is_error) {
throw new Error(`Claude Code returned an error: ${chunk.result}`)
}
usage.totalCost = isPaidUsage ? chunk.total_cost_usd : 0
yield usage
continue
}
// ErrorMessage — log it explicitly and skip
if ((chunk as any).type === "error") {
Logger.warn("Claude Code emitted an error-type chunk:", JSON.stringify(chunk))
continue
}
// Any completely unrecognized chunk type — log and skip
Logger.warn(`Unrecognized Claude Code chunk type: ${(chunk as any).type}`)
}
}
private attemptParse(str: string) {
try {
return JSON.parse(str)
} catch (_err) {
return null
}
}
getModel() {
const modelId = this.options.apiModelId
if (modelId && modelId in claudeCodeModels) {
const id = modelId as ClaudeCodeModelId
return { id, info: claudeCodeModels[id] }
}
return {
id: claudeCodeDefaultModelId,
info: claudeCodeModels[claudeCodeDefaultModelId],
}
}
}
-348
View File
@@ -1,348 +0,0 @@
import { clinePassModels, type ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "@shared/api"
import { shouldSkipReasoningForModel } from "@utils/model-utils"
import axios from "axios"
import OpenAI from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { ClineEnv } from "@/config"
import { refreshClineRecommendedModels } from "@/core/controller/models/refreshClineRecommendedModels"
import { ClineAccountService } from "@/services/account/ClineAccountService"
import { AuthService } from "@/services/auth/AuthService"
import { buildClineExtraHeaders } from "@/services/EnvUtils"
import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "@/shared/ClineAccount"
import { CLINE_RECOMMENDED_MODELS_FALLBACK } from "@/shared/cline/recommended-models"
import type { ClineStorageMessage } from "@/shared/messages/content"
import { fetch, getAxiosSettings } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
import type { ApiHandler, CommonApiHandlerOptions } from "../"
import { withRetry } from "../retry"
import { createOpenRouterStream } from "../transform/openrouter-stream"
import type { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { ToolCallProcessor } from "../transform/tool-call-processor"
import type { OpenRouterErrorResponse } from "./types"
interface ClineHandlerOptions extends CommonApiHandlerOptions {
ulid?: string
taskId?: string
reasoningEffort?: string
thinkingBudgetTokens?: number
openRouterProviderSorting?: string
openRouterModelId?: string
openRouterModelInfo?: ModelInfo
clineAccountId?: string
clineApiKey?: string
enableParallelToolCalling?: boolean
}
function normalizeModelId(modelId: string): string {
return modelId.trim().toLowerCase()
}
const CLINE_FREE_MODEL_IDS = new Set([
...CLINE_RECOMMENDED_MODELS_FALLBACK.free.map((model) => normalizeModelId(model.id)),
...Object.keys(clinePassModels).map((modelId) => normalizeModelId(modelId)),
])
function getCacheReadTokens(usage: any): number {
return usage?.prompt_tokens_details?.cached_tokens || usage?.cache_read_input_tokens || 0
}
function getCacheWriteTokens(usage: any): number {
return usage?.prompt_tokens_details?.cache_write_tokens || usage?.cache_creation_input_tokens || 0
}
export class ClineHandler implements ApiHandler {
private options: ClineHandlerOptions
private clineAccountService = ClineAccountService.getInstance()
private _authService: AuthService
private client: OpenAI | undefined
lastGenerationId?: string
private lastRequestId?: string
private get _baseUrl(): string {
return ClineEnv.config().apiBaseUrl
}
constructor(options: ClineHandlerOptions) {
this.options = options
this._authService = AuthService.getInstance()
}
private async getFreeModelIdSet(): Promise<Set<string>> {
try {
const models = await refreshClineRecommendedModels()
const freeModelIds = [...models.free, ...models.clinePass]
.map((model) => normalizeModelId(model.id))
.filter((modelId) => modelId.length > 0)
if (freeModelIds.length > 0) {
return new Set(freeModelIds)
}
} catch (error) {
Logger.error("Error resolving Cline free model IDs from recommended models:", error)
}
return CLINE_FREE_MODEL_IDS
}
private async ensureClient(): Promise<OpenAI> {
const clineAccountAuthToken = this.options.clineApiKey || (await this._authService.getAuthToken())
if (!clineAccountAuthToken) {
throw new Error(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE)
}
if (!this.client) {
try {
const defaultHeaders: Record<string, string> = {
"HTTP-Referer": "https://cline.bot",
"X-Title": "Cline",
"X-Task-ID": this.options.ulid || "",
}
Object.assign(defaultHeaders, await buildClineExtraHeaders())
this.client = new OpenAI({
baseURL: `${this._baseUrl}/api/v1`,
apiKey: clineAccountAuthToken,
defaultHeaders,
// Capture real HTTP request ID from initial streaming response headers
fetch: async (...args: Parameters<typeof fetch>): Promise<Awaited<ReturnType<typeof fetch>>> => {
const [input, init] = args
const resp = await fetch(input, init)
try {
let urlStr = ""
if (typeof input === "string") {
urlStr = input
} else if (input instanceof URL) {
urlStr = input.toString()
} else if (typeof (input as { url?: unknown }).url === "string") {
urlStr = (input as { url: string }).url
}
// Only record for chat completions (the primary streaming request)
if (urlStr.includes("/chat/completions")) {
const rid = resp.headers.get("x-request-id") || resp.headers.get("request-id")
if (rid) {
this.lastRequestId = rid
}
}
} catch {
// ignore header capture errors
}
return resp
},
})
} catch (error: any) {
throw new Error(`Error creating Cline client: ${error.message}`)
}
}
// Ensure the client is always using the latest auth token
this.client.apiKey = clineAccountAuthToken
return this.client
}
@withRetry()
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: OpenAITool[]): ApiStream {
try {
const client = await this.ensureClient()
this.lastGenerationId = undefined
this.lastRequestId = undefined
let didOutputUsage = false
const freeModelIds = await this.getFreeModelIdSet()
const stream = await createOpenRouterStream(
client,
systemPrompt,
messages,
this.getModel(),
this.options.reasoningEffort,
this.options.thinkingBudgetTokens,
this.options.openRouterProviderSorting,
tools,
this.options.enableParallelToolCalling,
)
const toolCallProcessor = new ToolCallProcessor()
for await (const chunk of stream) {
Logger.debug("ClineHandler chunk:" + JSON.stringify(chunk))
// openrouter returns an error object instead of the openai sdk throwing an error
if ("error" in chunk) {
const error = chunk.error as OpenRouterErrorResponse["error"]
Logger.error(`Cline API Error: ${error?.code} - ${error?.message}`)
// Include metadata in the error message if available
const metadataStr = error.metadata ? `\nMetadata: ${JSON.stringify(error.metadata, null, 2)}` : ""
throw new Error(`Cline API Error ${error.code}: ${error.message}${metadataStr}`)
}
if (!this.lastGenerationId && chunk.id) {
this.lastGenerationId = chunk.id
}
// Check for mid-stream error via finish_reason
const choice = chunk.choices?.[0]
// OpenRouter may return finish_reason = "error" with error details
if ((choice?.finish_reason as string) === "error") {
const choiceWithError = choice as any
if (choiceWithError.error) {
const error = choiceWithError.error
Logger.error(`Cline Mid-Stream Error: ${error.code || error.type || "Unknown"} - ${error.message}`)
throw new Error(`Cline Mid-Stream Error: ${error.code || error.type || "Unknown"} - ${error.message}`)
}
throw new Error("Cline Mid-Stream Error: Stream terminated with error status but no error details provided")
}
const delta = choice?.delta
if (delta?.content) {
yield {
type: "text",
text: delta.content,
}
}
if (delta?.tool_calls) {
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
}
// Reasoning tokens are returned separately from the content
// Skip reasoning content for Grok 4 models since it only displays "thinking" without providing useful information
if (
delta &&
"reasoning" in delta &&
delta.reasoning &&
!shouldSkipReasoningForModel(this.options.openRouterModelId)
) {
yield {
type: "reasoning",
reasoning: typeof delta.reasoning === "string" ? delta.reasoning : JSON.stringify(delta.reasoning),
}
}
/*
OpenRouter passes reasoning details that we can pass back unmodified in api requests to preserve reasoning traces for model
- The reasoning_details array in each chunk may contain one or more reasoning objects
- For encrypted reasoning, the content may appear as [REDACTED] in streaming responses
- The complete reasoning sequence is built by concatenating all chunks in order
See: https://openrouter.ai/docs/use-cases/reasoning-tokens#preserving-reasoning-blocks
*/
if (
delta &&
"reasoning_details" in delta &&
delta.reasoning_details &&
// @ts-expect-error-next-line
delta?.reasoning_details?.length && // exists and non-0
!shouldSkipReasoningForModel(this.options.openRouterModelId)
) {
yield {
type: "reasoning",
reasoning: "",
details: delta.reasoning_details,
}
}
if (!didOutputUsage && chunk.usage) {
// @ts-expect-error-next-line
let totalCost = (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0)
const modelId = this.getModel().id
const isFreeModel = freeModelIds.has(normalizeModelId(modelId))
const cacheReadTokens = getCacheReadTokens(chunk.usage)
const cacheWriteTokens = getCacheWriteTokens(chunk.usage)
if (isFreeModel) {
totalCost = 0
}
yield {
type: "usage",
cacheWriteTokens,
cacheReadTokens,
inputTokens: Math.max(0, (chunk.usage.prompt_tokens || 0) - cacheReadTokens - cacheWriteTokens),
outputTokens: chunk.usage.completion_tokens || 0,
totalCost,
}
didOutputUsage = true
}
}
// Fallback to generation endpoint if usage chunk not returned
if (!didOutputUsage) {
Logger.warn("Cline API did not return usage chunk, fetching from generation endpoint")
const apiStreamUsage = await this.getApiStreamUsage(freeModelIds)
if (apiStreamUsage) {
yield apiStreamUsage
}
}
} catch (error) {
Logger.error("Cline API Error:", error)
throw error
}
}
async getApiStreamUsage(freeModelIds?: Set<string>): Promise<ApiStreamUsageChunk | undefined> {
if (this.lastGenerationId) {
try {
const resolvedFreeModelIds = freeModelIds || (await this.getFreeModelIdSet())
const clineAccountAuthToken = await this._authService.getAuthToken()
if (!clineAccountAuthToken) {
throw new Error(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE)
}
const headers: Record<string, string> = {
// Align with backend auth expectations
Authorization: `Bearer ${clineAccountAuthToken}`,
}
Object.assign(headers, await buildClineExtraHeaders())
const response = await axios.get(`${this.clineAccountService.baseUrl}/generation?id=${this.lastGenerationId}`, {
headers,
timeout: 15_000, // this request hangs sometimes
...getAxiosSettings(),
})
const generation = response.data
let totalCost = generation?.total_cost || 0
const modelId = this.getModel().id
const isFreeModel = resolvedFreeModelIds.has(normalizeModelId(modelId))
if (isFreeModel) {
totalCost = 0
}
return {
type: "usage",
cacheWriteTokens: generation?.native_tokens_cache_write || 0,
cacheReadTokens: generation?.native_tokens_cached || 0,
// openrouter generation endpoint fails often
inputTokens: Math.max(
0,
(generation?.native_tokens_prompt || 0) -
(generation?.native_tokens_cached || 0) -
(generation?.native_tokens_cache_write || 0),
),
outputTokens: generation?.native_tokens_completion || 0,
totalCost,
}
} catch (error) {
// ignore if fails
Logger.error("Error fetching cline generation details:", error)
}
}
return undefined
}
// Expose the last HTTP request ID captured from response headers (X-Request-ID)
getLastRequestId(): string | undefined {
return this.lastRequestId
}
getModel(): { id: string; info: ModelInfo } {
const modelId = this.options.openRouterModelId
const modelInfo = this.options.openRouterModelInfo
if (modelId && modelInfo) {
return { id: modelId, info: modelInfo }
}
// If we have a model ID but no model info (e.g., CLI featured models),
// use the ID with default model info rather than falling back to a different model
if (modelId) {
return { id: modelId, info: openRouterDefaultModelInfo }
}
return { id: openRouterDefaultModelId, info: openRouterDefaultModelInfo }
}
}
@@ -1,152 +0,0 @@
import { DeepSeekModelId, deepSeekDefaultModelId, deepSeekModels, ModelInfo } from "@shared/api"
import { calculateApiCostOpenAI } from "@utils/cost"
import OpenAI from "openai"
import type {
ChatCompletionReasoningEffort,
ChatCompletionTool as OpenAITool,
} from "openai/resources/chat/completions"
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
import { ClineStorageMessage } from "@/shared/messages/content"
import { fetch } from "@/shared/net"
import { ApiHandler, CommonApiHandlerOptions } from "../"
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { addReasoningContent } from "../transform/r1-format"
import { ApiStream } from "../transform/stream"
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
interface DeepSeekHandlerOptions extends CommonApiHandlerOptions {
deepSeekApiKey?: string
apiModelId?: string
reasoningEffort?: string
}
export class DeepSeekHandler implements ApiHandler {
private options: DeepSeekHandlerOptions
private client: OpenAI | undefined
constructor(options: DeepSeekHandlerOptions) {
this.options = options
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.deepSeekApiKey) {
throw new Error("DeepSeek API key is required")
}
try {
this.client = new OpenAI({
baseURL: "https://api.deepseek.com/v1",
apiKey: this.options.deepSeekApiKey,
defaultHeaders: buildExternalBasicHeaders(),
fetch, // Use configured fetch with proxy support
})
} catch (error) {
throw new Error(`Error creating DeepSeek client: ${error.message}`)
}
}
return this.client
}
private async *yieldUsage(info: ModelInfo, usage: OpenAI.Completions.CompletionUsage | undefined): ApiStream {
// Deepseek reports total input AND cache reads/writes,
// see context caching: https://api-docs.deepseek.com/guides/kv_cache)
// where the input tokens is the sum of the cache hits/misses, just like OpenAI.
// This affects:
// 1) context management truncation algorithm, and
// 2) cost calculation
// Deepseek usage includes extra fields.
// Safely cast the prompt token details section to the appropriate structure.
interface DeepSeekUsage extends OpenAI.CompletionUsage {
prompt_cache_hit_tokens?: number
prompt_cache_miss_tokens?: number
}
const deepUsage = usage as DeepSeekUsage
const inputTokens = deepUsage?.prompt_tokens || 0 // sum of cache hits and misses
const outputTokens = deepUsage?.completion_tokens || 0
const cacheReadTokens = deepUsage?.prompt_cache_hit_tokens || 0
const cacheWriteTokens = deepUsage?.prompt_cache_miss_tokens || 0
const totalCost = calculateApiCostOpenAI(info, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens)
const nonCachedInputTokens = Math.max(0, inputTokens - cacheReadTokens - cacheWriteTokens) // this will always be 0
yield {
type: "usage",
inputTokens: nonCachedInputTokens,
outputTokens: outputTokens,
cacheWriteTokens: cacheWriteTokens,
cacheReadTokens: cacheReadTokens,
totalCost: totalCost,
}
}
@withRetry()
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: OpenAITool[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
const isDeepSeekReasonerModel = model.id.includes("deepseek-reasoner")
const isDeepSeekThinkingModel =
isDeepSeekReasonerModel || model.id === "deepseek-v4-flash" || model.id === "deepseek-v4-pro"
const convertedMessages = convertToOpenAiMessages(messages)
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = isDeepSeekReasonerModel
? [{ role: "system", content: systemPrompt }, ...addReasoningContent(convertedMessages, messages)]
: [{ role: "system", content: systemPrompt }, ...convertedMessages]
const stream = await client.chat.completions.create({
model: model.id,
max_completion_tokens: model.info.maxTokens,
messages: openAiMessages,
stream: true,
stream_options: { include_usage: true },
// Only set temperature for non-thinking models
...(isDeepSeekThinkingModel ? {} : { temperature: 0 }),
// DeepSeek thinking models accept reasoning effort (low/medium map to high, xhigh maps to max).
// "none" isn't a valid DeepSeek value, so omit it and let the API use its default.
...(isDeepSeekThinkingModel && this.options.reasoningEffort && this.options.reasoningEffort !== "none"
? { reasoning_effort: this.options.reasoningEffort as ChatCompletionReasoningEffort }
: {}),
...getOpenAIToolParams(tools),
})
const toolCallProcessor = new ToolCallProcessor()
for await (const chunk of stream) {
const delta = chunk.choices?.[0]?.delta
if (delta && "reasoning_content" in delta && delta.reasoning_content) {
yield {
type: "reasoning",
reasoning: (delta.reasoning_content as string | undefined) || "",
}
}
if (delta?.content) {
yield {
type: "text",
text: delta.content,
}
}
if (delta?.tool_calls) {
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
}
if (chunk.usage) {
yield* this.yieldUsage(model.info, chunk.usage)
}
}
}
getModel(): { id: DeepSeekModelId; info: ModelInfo } {
const modelId = this.options.apiModelId
if (modelId && modelId in deepSeekModels) {
const id = modelId as DeepSeekModelId
return { id, info: deepSeekModels[id] }
}
return {
id: deepSeekDefaultModelId,
info: deepSeekModels[deepSeekDefaultModelId],
}
}
}
-657
View File
@@ -1,657 +0,0 @@
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
import { ClineStorageMessage } from "@/shared/messages/content"
import { fetch } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
import { ModelInfo } from "../../../shared/api"
import { ApiHandler } from "../index"
import { ApiStream } from "../transform/stream"
interface DifyHandlerOptions {
difyApiKey?: string
difyBaseUrl?: string
}
// Dify API Response Types
export interface DifyFileResponse {
id: string
name: string
size: number
extension: string
mime_type: string
created_by: string
created_at: number
}
export interface DifyMessage {
id: string
conversation_id: string
inputs: Record<string, any>
query: string
message_files: Array<{
id: string
type: string
url: string
belongs_to: string
}>
answer: string
created_at: number
feedback?: {
rating: string
}
retriever_resources?: any[]
}
interface DifyHistoryResponse {
data: DifyMessage[]
has_more: boolean
limit: number
}
interface DifyConversation {
id: string
name: string
inputs: Record<string, any>
status: string
introduction: string
created_at: number
updated_at: number
}
interface DifyConversationsResponse {
data: DifyConversation[]
has_more: boolean
limit: number
}
interface DifyConversationResponse {
id: string
name: string
inputs: Record<string, any>
status: string
introduction: string
created_at: number
updated_at: number
}
export class DifyHandler implements ApiHandler {
private options: DifyHandlerOptions
private baseUrl: string
private apiKey: string
private conversationId: string | null = null
private currentTaskId: string | null = null
private abortController: AbortController | null = null
constructor(options: DifyHandlerOptions) {
this.options = options
this.apiKey = options.difyApiKey || ""
this.baseUrl = options.difyBaseUrl || ""
Logger.log("[DIFY DEBUG] Constructor called with:", {
hasApiKey: !!this.apiKey,
baseUrl: this.baseUrl,
})
if (!this.apiKey) {
throw new Error("Dify API key is required")
}
if (!this.baseUrl) {
throw new Error("Dify base URL is required")
}
}
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[]): ApiStream {
Logger.log("[DIFY DEBUG] createMessage called with:", {
systemPromptLength: systemPrompt?.length || 0,
messagesCount: messages?.length || 0,
})
// Convert messages to Dify format
const query = this.convertMessagesToQuery(systemPrompt, messages)
const requestBody = {
inputs: {},
query: query,
response_mode: "streaming",
conversation_id: this.conversationId || "",
user: "cline-user", // A unique user identifier
files: [],
}
const fullUrl = `${this.baseUrl}/chat-messages`
Logger.log("[DIFY DEBUG] Making request to:", fullUrl)
Logger.log("[DIFY DEBUG] Request body:", JSON.stringify(requestBody, null, 2))
let response: Response
try {
response = await fetch(fullUrl, {
method: "POST",
headers: this.jsonHeaders(),
body: JSON.stringify(requestBody),
})
} catch (error: any) {
Logger.error("[DIFY DEBUG] Network error during fetch:", error)
const cause = error.cause ? ` | Cause: ${error.cause}` : ""
throw new Error(`Dify API network error: ${error.message}${cause}`)
}
Logger.log("[DIFY DEBUG] Response status:", response.status)
const headersObj: Record<string, string> = {}
response.headers.forEach((value, key) => {
headersObj[key] = value
})
Logger.log("[DIFY DEBUG] Response headers:", headersObj)
if (!response.ok) {
const errorText = await response.text()
Logger.error("[DIFY DEBUG] Error response:", errorText)
throw new Error(`Dify API error: ${response.status} ${response.statusText} - ${errorText}`)
}
if (!response.body) {
throw new Error("No response body from Dify API")
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
let fullText = ""
let hasYieldedContent = false
const processedEvents: string[] = []
let lastEventTime = Date.now()
Logger.log("[DIFY DEBUG] Starting to read streaming response...")
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
Logger.log("[DIFY DEBUG] Stream ended naturally")
Logger.log(
"[DIFY DEBUG] Final state - hasYieldedContent:",
hasYieldedContent,
"fullText length:",
fullText.length,
"processedEvents:",
processedEvents,
)
break
}
const chunk = decoder.decode(value, { stream: true })
Logger.log("[DIFY DEBUG] Raw chunk received:", JSON.stringify(chunk))
buffer += chunk
const lines = buffer.split("\n")
// Keep the last incomplete line in the buffer
buffer = lines.pop() || ""
for (const line of lines) {
Logger.log("[DIFY DEBUG] Processing line:", JSON.stringify(line))
if (line.startsWith("data: ")) {
const data = line.slice(6).trim()
Logger.log("[DIFY DEBUG] Extracted data:", JSON.stringify(data))
if (data === "[DONE]") {
Logger.log("[DIFY DEBUG] Received [DONE] signal")
break
}
if (data === "") {
Logger.log("[DIFY DEBUG] Empty data line, skipping")
continue
}
try {
const parsed = JSON.parse(data)
Logger.log("[DIFY DEBUG] Parsed JSON:", parsed)
processedEvents.push(parsed.event || "unknown")
lastEventTime = Date.now()
// Capture conversation_id as soon as it's available
if (parsed.conversation_id && !this.conversationId) {
this.conversationId = parsed.conversation_id
Logger.log("[DIFY DEBUG] Captured conversation_id:", this.conversationId)
}
// Handle different Dify event types based on actual Dify API
if (parsed.event === "message") {
Logger.log("[DIFY DEBUG] Message event, answer:", parsed.answer)
// Dify sends the full text in each "answer" chunk, so we replace.
if (typeof parsed.answer === "string") {
fullText = parsed.answer
Logger.log("[DIFY DEBUG] Updated fullText length:", fullText.length)
yield {
type: "text",
text: fullText,
}
hasYieldedContent = true
}
} else if (parsed.event === "message_replace") {
Logger.log("[DIFY DEBUG] Replace message event:", parsed)
if (parsed.answer) {
fullText = parsed.answer // Replace instead of append
Logger.log("[DIFY DEBUG] Replaced fullText length:", fullText.length)
yield {
type: "text",
text: fullText,
}
hasYieldedContent = true
}
} else if (parsed.event === "message_end") {
Logger.log("[DIFY DEBUG] Message end event", parsed)
// Message completed. Yield final text if we have any.
if (fullText) {
yield {
type: "text",
text: fullText,
}
hasYieldedContent = true
}
// Yield usage data if available
if (parsed.usage) {
yield {
type: "usage",
inputTokens: parsed.usage.prompt_tokens || 0,
outputTokens: parsed.usage.completion_tokens || parsed.usage.total_tokens || 0,
totalCost: parsed.usage.total_price || 0,
}
}
return // End of stream
} else if (parsed.event === "error") {
Logger.error("[DIFY DEBUG] Error event:", parsed)
throw new Error(`Dify API error: ${parsed.message || "Unknown error"}`)
} else if (parsed.event === "workflow_started" || parsed.event === "workflow_finished") {
Logger.log("[DIFY DEBUG] Workflow event:", parsed.event)
// These are informational events, continue processing
} else if (parsed.event === "node_started" || parsed.event === "node_finished") {
Logger.log("[DIFY DEBUG] Node event:", parsed.event, parsed.data)
// These are informational events, continue processing
} else if (parsed.event === "ping") {
Logger.log("[DIFY DEBUG] Ping event received, keeping connection alive.")
// Ping event, do nothing
} else {
Logger.log("[DIFY DEBUG] Unknown event type:", parsed.event, "Full object:", parsed)
// Try to extract text from other possible fields
if (parsed.text) {
fullText += parsed.text
yield {
type: "text",
text: fullText,
}
hasYieldedContent = true
} else if (parsed.content) {
fullText += parsed.content
yield {
type: "text",
text: fullText,
}
hasYieldedContent = true
} else if (parsed.answer) {
// Fallback: some events might have answer field even if not "message" type
fullText += parsed.answer
yield {
type: "text",
text: fullText,
}
hasYieldedContent = true
}
}
} catch (e) {
Logger.warn("[DIFY DEBUG] Failed to parse JSON:", data, "Error:", e)
}
} else if (line.trim() !== "") {
Logger.log(
"[DIFY DEBUG] Non-data line (not starting with 'data:'), trying to parse as direct JSON:",
JSON.stringify(line),
)
// Try to parse as direct JSON (fallback for non-SSE responses, though Dify uses SSE)
try {
const parsed = JSON.parse(line.trim())
Logger.log("[DIFY DEBUG] Parsed direct JSON:", parsed)
processedEvents.push(parsed.event || "direct-json")
// Handle the same event types as above
if (parsed.event === "message" && parsed.answer) {
fullText += parsed.answer
yield {
type: "text",
text: fullText,
}
hasYieldedContent = true
} else if (parsed.event === "message_end") {
if (fullText) {
yield {
type: "text",
text: fullText,
}
hasYieldedContent = true
}
return
} else if (parsed.event === "error") {
Logger.error("[DIFY DEBUG] Direct JSON Error event:", parsed)
throw new Error(`Dify API error: ${parsed.message || "Unknown error"}`)
} else if (parsed.answer || parsed.text || parsed.content) {
// Fallback for any content in direct JSON
const content = parsed.answer || parsed.text || parsed.content
fullText += content
yield {
type: "text",
text: fullText,
}
hasYieldedContent = true
}
} catch (e) {
// Not JSON, continue
Logger.log("[DIFY DEBUG] Line is not direct JSON, continuing")
}
}
}
}
// Final check - if we haven't yielded any content, provide diagnostic information
if (!hasYieldedContent) {
const diagnosticInfo = {
processedEvents,
finalFullTextLength: fullText.length,
finalFullText: fullText,
streamDuration: Date.now() - lastEventTime,
conversationId: this.conversationId,
}
Logger.error("[DIFY DEBUG] No content was yielded! Diagnostic info:", diagnosticInfo)
// If we have any accumulated text at all, yield it as a fallback
if (fullText.trim()) {
Logger.log("[DIFY DEBUG] Yielding accumulated text as fallback:", fullText)
yield {
type: "text",
text: fullText,
}
} else {
// Provide a more informative error
throw new Error(
`Dify API did not provide any assistant messages. ` +
`Events processed: [${processedEvents.join(", ")}]. ` +
`Check your Dify application configuration and ensure it's properly set up to return responses. ` +
`API URL: ${fullUrl}. Conversation ID: ${this.conversationId || "none"}.`,
)
}
}
} finally {
reader.releaseLock()
Logger.log("[DIFY DEBUG] Stream reader released")
}
}
private convertMessagesToQuery(systemPrompt: string, messages: ClineStorageMessage[]): string {
// Dify's context is managed by `conversation_id`. The `query` should be the last user message.
// The system prompt is typically configured in the Dify App itself.
const lastUserMessage = messages.filter((m) => m.role === "user").pop()
if (!lastUserMessage) {
return "" // Should not happen in normal flow
}
const userQuery = Array.isArray(lastUserMessage.content)
? lastUserMessage.content.map((c) => ("text" in c ? c.text : "")).join("\n")
: (lastUserMessage.content as string)
// Only prepend the system prompt if it's the very first message of a new conversation.
if (!this.conversationId && systemPrompt) {
Logger.log("[DIFY DEBUG] Prepending system prompt for new conversation.")
return `${systemPrompt}\n\n---\n\n${userQuery}`
}
return userQuery
}
getModel(): { id: string; info: ModelInfo } {
return {
id: "dify-workflow",
info: {
maxTokens: 8192,
contextWindow: 128000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Dify workflow - model selection is configured in your Dify application",
},
}
}
// Additional Dify API Methods
/**
* Upload a file for use in conversations
* @param file File buffer to upload
* @param filename Name of the file
* @param user User identifier (defaults to "cline-user")
* @returns Promise with file upload response
*/
async uploadFile(file: Buffer, filename: string, user: string = "cline-user"): Promise<DifyFileResponse> {
const formData = new FormData()
formData.append("file", new Blob([new Uint8Array(file)]), filename)
formData.append("user", user)
const response = await fetch(`${this.baseUrl}/files/upload`, {
method: "POST",
headers: this.headers(),
body: formData,
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Dify file upload error: ${response.status} ${response.statusText} - ${errorText}`)
}
return response.json()
}
/**
* Stop generation for a specific task
* @param taskId Task ID from streaming response
* @param user User identifier (defaults to "cline-user")
* @returns Promise that resolves when generation is stopped
*/
async stopGeneration(taskId: string, user: string = "cline-user"): Promise<void> {
const response = await fetch(`${this.baseUrl}/chat-messages/${taskId}/stop`, {
method: "POST",
headers: this.jsonHeaders(),
body: JSON.stringify({ user }),
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Dify stop generation error: ${response.status} ${response.statusText} - ${errorText}`)
}
}
/**
* Get conversation history messages with pagination
* @param conversationId Conversation ID
* @param user User identifier (defaults to "cline-user")
* @param firstId First message ID for pagination (optional)
* @param limit Number of messages to return (default: 20)
* @returns Promise with conversation history
*/
async getConversationHistory(
conversationId: string,
user: string = "cline-user",
firstId?: string,
limit: number = 20,
): Promise<DifyHistoryResponse> {
const params = new URLSearchParams({ user, limit: limit.toString() })
if (firstId) {
params.append("first_id", firstId)
}
const response = await fetch(`${this.baseUrl}/conversations/${conversationId}/messages?${params}`, {
headers: this.headers(),
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Dify get conversation history error: ${response.status} ${response.statusText} - ${errorText}`)
}
return response.json()
}
/**
* Get list of conversations for a user
* @param user User identifier (defaults to "cline-user")
* @param lastId Last conversation ID for pagination (optional)
* @param limit Number of conversations to return (default: 20)
* @param sortBy Sort field (default: "-updated_at")
* @returns Promise with conversations list
*/
async getConversations(
user: string = "cline-user",
lastId?: string,
limit: number = 20,
sortBy: string = "-updated_at",
): Promise<DifyConversationsResponse> {
const params = new URLSearchParams({
user,
limit: limit.toString(),
sort_by: sortBy,
})
if (lastId) {
params.append("last_id", lastId)
}
const response = await fetch(`${this.baseUrl}/conversations?${params}`, {
headers: this.headers(),
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Dify get conversations error: ${response.status} ${response.statusText} - ${errorText}`)
}
return response.json()
}
/**
* Delete a conversation
* @param conversationId Conversation ID to delete
* @param user User identifier (defaults to "cline-user")
* @returns Promise that resolves when conversation is deleted
*/
async deleteConversation(conversationId: string, user: string = "cline-user"): Promise<void> {
const response = await fetch(`${this.baseUrl}/conversations/${conversationId}`, {
method: "DELETE",
headers: this.jsonHeaders(),
body: JSON.stringify({ user }),
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Dify delete conversation error: ${response.status} ${response.statusText} - ${errorText}`)
}
}
/**
* Rename a conversation
* @param conversationId Conversation ID to rename
* @param user User identifier (defaults to "cline-user")
* @param name New conversation name (optional if auto_generate is true)
* @param autoGenerate Whether to auto-generate the name (default: false)
* @returns Promise with updated conversation details
*/
async renameConversation(
conversationId: string,
user: string = "cline-user",
name?: string,
autoGenerate: boolean = false,
): Promise<DifyConversationResponse> {
const body: any = { user, auto_generate: autoGenerate }
if (name) {
body.name = name
}
const response = await fetch(`${this.baseUrl}/conversations/${conversationId}/name`, {
method: "POST",
headers: this.jsonHeaders(),
body: JSON.stringify(body),
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Dify rename conversation error: ${response.status} ${response.statusText} - ${errorText}`)
}
return response.json()
}
/**
* Submit feedback for a message
* @param messageId Message ID to provide feedback for
* @param rating Rating: "like" or "dislike"
* @param content Optional feedback content
* @param user User identifier (defaults to "cline-user")
* @returns Promise that resolves when feedback is submitted
*/
async submitMessageFeedback(
messageId: string,
rating: "like" | "dislike",
content?: string,
user: string = "cline-user",
): Promise<void> {
const body: any = { rating, user }
if (content) {
body.content = content
}
const response = await fetch(`${this.baseUrl}/messages/${messageId}/feedbacks`, {
method: "POST",
headers: this.jsonHeaders(),
body: JSON.stringify(body),
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Dify submit feedback error: ${response.status} ${response.statusText} - ${errorText}`)
}
}
/**
* Get current conversation ID
* @returns Current conversation ID or null
*/
getCurrentConversationId(): string | null {
return this.conversationId
}
/**
* Set conversation ID for continuing existing conversations
* @param conversationId Conversation ID to set
*/
setConversationId(conversationId: string): void {
this.conversationId = conversationId
}
/**
* Reset conversation ID to start a new conversation
*/
resetConversation(): void {
this.conversationId = null
this.currentTaskId = null
}
private jsonHeaders() {
return {
...this.headers(),
"Content-Type": "application/json",
}
}
private headers() {
const externalHeaders = buildExternalBasicHeaders()
return {
...externalHeaders,
Authorization: `Bearer ${this.apiKey}`,
}
}
}
@@ -1,90 +0,0 @@
import { DoubaoModelId, doubaoDefaultModelId, doubaoModels, ModelInfo } from "@shared/api"
import OpenAI from "openai"
import { ClineStorageMessage } from "@/shared/messages/content"
import { createOpenAIClient } from "@/shared/net"
import { ApiHandler, CommonApiHandlerOptions } from ".."
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
interface DoubaoHandlerOptions extends CommonApiHandlerOptions {
doubaoApiKey?: string
apiModelId?: string
}
export class DoubaoHandler implements ApiHandler {
private options: DoubaoHandlerOptions
private client: OpenAI | undefined
constructor(options: DoubaoHandlerOptions) {
this.options = options
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.doubaoApiKey) {
throw new Error("Doubao API key is required")
}
try {
this.client = createOpenAIClient({
baseURL: "https://ark.cn-beijing.volces.com/api/v3/",
apiKey: this.options.doubaoApiKey,
})
} catch (error) {
throw new Error(`Error creating Doubao client: ${error.message}`)
}
}
return this.client
}
getModel(): { id: DoubaoModelId; info: ModelInfo } {
const modelId = this.options.apiModelId
if (modelId && modelId in doubaoModels) {
const id = modelId as DoubaoModelId
return { id, info: doubaoModels[id] }
}
return {
id: doubaoDefaultModelId,
info: doubaoModels[doubaoDefaultModelId],
}
}
@withRetry()
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
const stream = await client.chat.completions.create({
model: model.id,
max_completion_tokens: model.info.maxTokens,
messages: openAiMessages,
stream: true,
stream_options: { include_usage: true },
temperature: 0,
})
for await (const chunk of stream) {
const delta = chunk.choices?.[0]?.delta
if (delta?.content) {
yield {
type: "text",
text: delta.content,
}
}
if (chunk.usage) {
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-expect-error-next-line
cacheReadTokens: chunk.usage.prompt_cache_hit_tokens || 0,
// @ts-expect-error-next-line
cacheWriteTokens: chunk.usage.prompt_cache_miss_tokens || 0,
}
}
}
}
}
@@ -1,116 +0,0 @@
import { FireworksModelId, fireworksDefaultModelId, fireworksModels, ModelInfo } from "@shared/api"
import OpenAI from "openai"
import { ClineStorageMessage } from "@/shared/messages/content"
import { createOpenAIClient } from "@/shared/net"
import { ApiHandler, CommonApiHandlerOptions } from ".."
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
interface FireworksHandlerOptions extends CommonApiHandlerOptions {
fireworksApiKey?: string
fireworksModelId?: string
fireworksModelMaxCompletionTokens?: number
fireworksModelMaxTokens?: number
}
export class FireworksHandler implements ApiHandler {
private options: FireworksHandlerOptions
private client: OpenAI | undefined
constructor(options: FireworksHandlerOptions) {
this.options = options
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.fireworksApiKey) {
throw new Error("Fireworks API key is required")
}
try {
this.client = createOpenAIClient({
baseURL: "https://api.fireworks.ai/inference/v1",
apiKey: this.options.fireworksApiKey,
})
} catch (error) {
throw new Error(`Error creating Fireworks client: ${error.message}`)
}
}
return this.client
}
@withRetry()
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[]): ApiStream {
const client = this.ensureClient()
const modelId = this.options.fireworksModelId ?? ""
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
const stream = await client.chat.completions.create({
model: modelId,
messages: openAiMessages,
stream: true,
stream_options: { include_usage: true },
temperature: 0,
})
let reasoning: string | null = null
for await (const chunk of stream) {
const delta = chunk.choices?.[0]?.delta
if (reasoning || delta?.content?.includes("<think>")) {
reasoning = (reasoning || "") + (delta.content ?? "")
}
if (delta?.content && !reasoning) {
yield {
type: "text",
text: delta.content,
}
}
if (reasoning || (delta && "reasoning_content" in delta && delta.reasoning_content)) {
yield {
type: "reasoning",
reasoning: delta.content || ((delta as any).reasoning_content as string | undefined) || "",
}
if (reasoning?.includes("</think>")) {
// Reset so the next chunk is regular content
reasoning = null
}
}
if (chunk.usage) {
const usage = chunk.usage as OpenAI.CompletionUsage & {
prompt_cache_hit_tokens?: number
prompt_cache_miss_tokens?: number
prompt_tokens_details?: {
cached_tokens?: number
}
}
yield {
type: "usage",
inputTokens: usage.prompt_tokens || 0,
outputTokens: usage.completion_tokens || 0,
// Fireworks can return cache hits either as prompt_cache_hit_tokens or prompt_tokens_details.cached_tokens.
cacheReadTokens: usage.prompt_cache_hit_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? 0,
cacheWriteTokens: usage.prompt_cache_miss_tokens || 0,
}
}
}
}
getModel(): { id: FireworksModelId; info: ModelInfo } {
const modelId = this.options.fireworksModelId
if (modelId && modelId in fireworksModels) {
const id = modelId as FireworksModelId
return { id, info: fireworksModels[id] }
}
return {
id: fireworksDefaultModelId,
info: fireworksModels[fireworksDefaultModelId],
}
}
}

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