Compare commits

..
Author SHA1 Message Date
Saoud Rizwan c865b72e55 refactor(vscode): render providers from sdk metadata 2026-06-21 14:17:34 -07:00
Max 0c39279a49 fix(vscode): use Codex OAuth credentials (#11691) 2026-06-20 08:20:07 -07:00
BarreiroT d1faa633bd Fix tests 2026-06-20 11:34:56 -03:00
BarreiroT 4623cd3e46 fix tests 2026-06-19 21:08:14 -03:00
Tomás Barreiro b314df51c2 Fix ClinePass auth (#11680)
* Return local providers with ClinePass in the new extension

* Fix import

* Fix ClinePass auth
2026-06-20 01:32:23 +02:00
Tomás Barreiro 4f12662ee0 Return local providers with ClinePass in the new extension (#11678)
* Return local providers with ClinePass in the new extension

* Fix import
2026-06-19 23:58:39 +02:00
Max Paulus 🥪 f1c81047eb fix broken CI tests 2026-06-19 13:17:47 -07:00
Max Paulus 🥪 78c97ee124 fix local build not picking up .env file 2026-06-19 12:04:02 -07:00
Max Paulus 🥪 527f2af2e3 update vscode ignore
vsix bundling failed because of some unignored files
2026-06-19 10:13:56 -07:00
Max Paulus 🥪 0056b08300 fix broken integ tests 2026-06-19 10:13:55 -07:00
Max Paulus 🥪 035cce0ac5 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-19 10:13:55 -07:00
Cline Agent ef76b1b977 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-19 09:58:09 -04:00
Cline Agent 0e1da42925 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-18 22:45:48 -04:00
dcf78364ca 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-18 22:27:04 -04:00
Dominic CooneyandCline Agent 4c03c79b4b 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-18 22:27:03 -04:00
MaxandMax Paulus 🥪 9c61adbd87 Improve onboarding funnel metrics (#11650)
* improve onboarding metrics

* fix onboarding page view dedupe

---------

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-06-18 22:25:16 -04:00
Max Paulus 🥪 5919b0fa09 fix package lock issues post rebase 2026-06-18 22:23:02 -04:00
MaxandMax Paulus 🥪 9312f62e09 fix(vscode): persist Vertex provider settings (#11565)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-06-18 22:21:43 -04:00
MaxandMax Paulus 🥪 b4e3cb1d2b fix(vscode): preserve legacy task metadata on resume (#11570)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-06-18 22:21:43 -04:00
Dominic Cooney 42163a1229 chore(vscode): remove stale HuggingFace provider test 2026-06-18 22:21:43 -04:00
Max Paulus 🥪 7337926c6e fix standalone e2e test 2026-06-18 22:21:43 -04:00
Max Paulus 🥪 9066e6c522 bump sdk version 2026-06-18 22:21:43 -04:00
Dominic Cooney e021e0777b 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-18 22:21:43 -04:00
Saoud Rizwan c9d51f6332 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-18 22:21:43 -04:00
Mikołaj Kondratek ffe446d55d 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-18 22:21:43 -04:00
Saoud Rizwan c446ca7def 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-18 22:21:43 -04:00
Saoud Rizwan 41418bc201 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-18 22:21:43 -04:00
Dominic Cooney 48a4016077 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-18 22:21:43 -04:00
Robin Newhouse 7e2bc6488f fix(vscode): stabilize SDK e2e login flow (#11441) 2026-06-18 22:21:43 -04:00
Dominic Cooney 6ab316ad82 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-18 22:21:43 -04:00
Saoud Rizwan 361f35841a 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-18 22:21:43 -04:00
Saoud Rizwan e10b6e68dd 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-18 22:21:43 -04:00
Robin NewhouseandCursor 713631e1ea 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-18 22:21:43 -04:00
Max Paulus 🥪 0194c98b0f include optional deps so that CI passes 2026-06-18 22:21:43 -04:00
Max Paulus 🥪 cbd73d2c1f fix broken tests 2026-06-18 22:21:43 -04:00
Max Paulus 🥪 25fd83ac4e bump sdk version 2026-06-18 22:21:43 -04:00
Max Paulus 🥪 86c61b96f6 add vertex support to extension 2026-06-18 22:21:43 -04:00
Mikołaj Kondratek 0cba47c145 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-18 22:21:43 -04:00
Max Paulus 🥪 ead0f9373a fix telemtry opt flag migration 2026-06-18 22:21:43 -04:00
Dominic Cooney 1ff2fe7887 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-18 22:21:43 -04:00
Max Paulus 🥪 6b2c9213d0 migrate telemetry value in extension 2026-06-18 22:21:43 -04:00
Max Paulus 🥪 5ee67a7f20 bump sdk version 2026-06-18 22:21:42 -04:00
Max Paulus 🥪 2a9057f184 fix claude-code setting loading/persistence 2026-06-18 22:21:42 -04:00
Max Paulus 🥪 ae3234212c fix task history delete 2026-06-18 22:21:42 -04:00
Max Paulus 🥪 24c489eceb fix model selector not showing most up to date model in providers.json 2026-06-18 22:21:42 -04:00
Max Paulus 🥪 b3ccece14e fix ui test 2026-06-18 22:21:42 -04:00
Max Paulus 🥪 7c4cf02657 fix ci checks 2026-06-18 22:21:42 -04:00
Robin Newhouse 2c6de4c6a6 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-18 22:21:42 -04:00
Max Paulus 🥪 4d72d4e6f4 fix anthropic provider settings persistence 2026-06-18 22:21:42 -04:00
Max Paulus 🥪 82c8f28dae remove baseUrl from providers.json when unchecking box in ui 2026-06-18 22:21:42 -04:00
Max Paulus 🥪 b31bed5b60 fix ollama and lmtudio settings persistence 2026-06-18 22:21:42 -04:00
Max Paulus 🥪 ec69289e40 fix openrouter apikey persist to providers.json 2026-06-18 22:21:42 -04:00
Max Paulus 🥪 fed81c55b1 fix vscodelm provider settings persist 2026-06-18 22:21:42 -04:00
Max Paulus 🥪 2fc24d30de persist bedrock settings to providers.json 2026-06-18 22:21:42 -04:00
Max Paulus 🥪 178d3db1ab don't block user input when hasNoUsableProvider == true 2026-06-18 22:21:42 -04:00
Mikołaj Kondratek 685103b193 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-18 22:21:42 -04:00
Ara 015ece10b6 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-18 22:21:42 -04:00
Max Paulus 🥪 155c38f7c8 remove unused code/files
fix broken webview build

remove unused code/files

fix broken webview build
2026-06-18 22:21:42 -04:00
Max Paulus 🥪 4619bb78bc delete unused files 2026-06-18 22:16:51 -04:00
Max Paulus 🥪 7799128c42 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-18 22:16:51 -04:00
Max Paulus 🥪 851d3857c2 fix webview-ui tests 2026-06-18 22:16:51 -04:00
Max Paulus 🥪 9536a39cb7 show model list if possible for openai compatible 2026-06-18 22:16:51 -04:00
Max Paulus 🥪 63a78519a3 fix onboarding model selection not persisting 2026-06-18 22:16:51 -04:00
Max Paulus 🥪 91b1ecd354 remove provider-specific views and just use genericprovidersettings.tsx 2026-06-18 22:11:53 -04:00
Max Paulus 🥪 1e6c2c0286 dry up duplicate code and create useProviderModelSelection 2026-06-18 22:11:53 -04:00
Max Paulus 🥪 571f2fbde8 dry up provider api key logic 2026-06-18 22:11:53 -04:00
Max Paulus 🥪 de2094f27f dry up some duplicate code 2026-06-18 22:11:53 -04:00
Max Paulus 🥪 8f43de8c2a fix onboarding models 2026-06-18 22:11:53 -04:00
Max Paulus 🥪 16a3895134 fix failing biome/lint 2026-06-18 22:10:34 -04:00
Mikołaj Kondratek 1deb0f4c52 Remove unused import 2026-06-18 22:09:08 -04:00
Mikołaj Kondratek 5b71d48b31 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-18 22:09:08 -04:00
Mikołaj Kondratek a37cb01c8a 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-18 22:09:08 -04:00
Ara aa7b06f5c9 Remove Explain Changes feature (#11278)
* chore(vscode): remove explain changes entry points

* chore(vscode): remove explain changes feature
2026-06-18 22:09:08 -04:00
Max Paulus 🥪 2310ce56ae make extension plan mode more similar to CLI
- basically, don't auto continue when agent switches to act mode
2026-06-18 22:09:08 -04:00
Max Paulus 🥪 69976c2a23 fix zai insufficient credits issue 2026-06-18 22:09:08 -04:00
Max Paulus 🥪 f8d3220c86 fix tool use name sanitization 2026-06-18 22:08:09 -04:00
Max Paulus 🥪 161682d858 fix broken tsc 2026-06-18 22:08:09 -04:00
Dominic Cooney a7f4d4778b 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-18 22:08:09 -04:00
Dominic Cooney 2f00a50fed 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-18 22:08:09 -04:00
Dominic Cooney 6313920e71 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-18 22:08:09 -04:00
Dominic Cooney f2eb188eed 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-18 22:08:09 -04:00
Dominic Cooney f8c81f1148 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-18 22:08:09 -04:00
Ara 5360a2af4f 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-18 22:08:09 -04:00
Max Paulus 🥪 963fac8f6d Persist OpenRouter provider config via catalog hook 2026-06-18 22:08:09 -04:00
Max Paulus 🥪 a89795fefb persist openai-codex provider model settings
- also don't show a sign in button if openai-codex is the only provider
2026-06-18 22:08:09 -04:00
Max Paulus 🥪 7addf92aea Persist Cline model selections to provider config 2026-06-18 22:08:09 -04:00
Dominic Cooney 99765931db 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-18 22:08:09 -04:00
Max Paulus 🥪 00672f2fd0 show legacy task history that is not saved in the ~/.cline folder 2026-06-18 22:06:01 -04:00
Max Paulus 🥪 efc5d3ade5 add migration telemetry 2026-06-18 22:06:01 -04:00
Ara a1b596c3ef fix(vscode): reuse approved tool rows (#11213)
* fix(vscode): reuse approved tool rows

* fix(vscode): clear stale approved tool rows
2026-06-18 22:06:01 -04:00
Dominic Cooney 7e917746a0 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-18 22:06:01 -04:00
273 changed files with 9448 additions and 19740 deletions
+2 -22
View File
@@ -169,8 +169,7 @@
"isBackground": true,
"label": "npm: watch:esbuild",
"dependsOn": [
"npm: protos",
"build-sdk:debug"
"npm: protos"
],
"presentation": {
"group": "watch",
@@ -209,8 +208,7 @@
"isBackground": true,
"label": "npm: watch:esbuild:test",
"dependsOn": [
"npm: protos",
"build-sdk:debug"
"npm: protos"
],
"presentation": {
"group": "watch",
@@ -313,24 +311,6 @@
"options": {
"cwd": "${workspaceFolder}"
}
},
{
"label": "build-sdk:debug",
"type": "shell",
"command": "bun run build:sdk",
"problemMatcher": [
"$tsc"
],
"presentation": {
"group": "watch",
"reveal": "always"
},
"options": {
"cwd": "${workspaceFolder}",
"env": {
"CLINE_SOURCEMAPS": "1"
}
}
}
],
"inputs": [
-16
View File
@@ -1,21 +1,5 @@
# Cline CLI Changelog
## 3.0.29
- Costs are now hidden for Cline free models
- Fixed Z.ai model metadata resolution for Z.ai models accessed through the Cline provider
- Reverted the model-name-only display change from v3.0.28; the model picker, selector, and status bar return to their previous display behavior
## 3.0.28
- Added a ClinePass onboarding flow with selectable ClinePass models, plus improved ClinePass error handling
- Added hub primitive catalogs and refreshed the hub dashboard design with a dedicated customizations breakout
- Auto-approve toggles now apply immediately when changed
- Feature flags now resolve using your user ID on startup
- Fixed Cline model display names so they resolve by model name
- Truncate large tool results by default (including MCP and custom tool output) to keep requests within context budget
- Hardened parallel tool-call guidance for faster, more reliable multi-tool execution
## 3.0.27
- Added a `cline skill` command to install and manage skills, matching `cline plugin install` and `cline mcp` (installs default to the Cline agent directory)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.29",
"version": "3.0.27",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+36 -2
View File
@@ -1,2 +1,36 @@
export type { ConnectorCatalogEntry } from "@cline/shared";
export { CONNECTOR_CATALOG, listConnectorCatalog } from "@cline/shared";
export type ConnectorCatalogEntry = {
name: string;
description: string;
};
export const CONNECTOR_CATALOG: ConnectorCatalogEntry[] = [
{
name: "discord",
description:
"Discord interactions and gateway bridge backed by RPC runtime sessions",
},
{
name: "gchat",
description: "Google Chat webhook bridge backed by RPC runtime sessions",
},
{
name: "linear",
description: "Linear webhook bridge backed by RPC runtime sessions",
},
{
name: "slack",
description: "Slack webhook/socket bridge backed by RPC runtime sessions",
},
{
name: "telegram",
description: "Bridge Telegram bot messages into RPC chat sessions",
},
{
name: "whatsapp",
description: "Bridge WhatsApp webhook messages into RPC chat sessions",
},
];
export function listConnectorCatalog(): ConnectorCatalogEntry[] {
return CONNECTOR_CATALOG.map((entry) => ({ ...entry }));
}
+2 -145
View File
@@ -27,25 +27,11 @@ const outputMocks = vi.hoisted(() => ({
c: { dim: "", reset: "" },
}));
const sessionEventsMocks = vi.hoisted(() => ({
listener: undefined as ((event: unknown) => void) | undefined,
subscribeToAgentEvents: vi.fn(
(_: unknown, listener: (event: unknown) => void) => {
sessionEventsMocks.listener = listener;
return () => {};
},
),
}));
const CLINE_PASS_SUBSCRIPTION_URL =
"https://app.cline.bot/dashboard/subscription?personal=true";
"https://app.cline.bot/dashboard/subscription/";
const CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLINE_PASS_SUBSCRIPTION_URL}`;
const CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE =
"Organization accounts cannot use ClinePass subscriptions. Go to /account -> change account to switch to your personal account for ClinePass";
vi.mock("@cline/core", () => ({
getClineOrgIndividualInferenceSubscriptionMessage: () =>
CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE,
getClinePassSubscriptionUrl: () => CLINE_PASS_SUBSCRIPTION_URL,
isClineNotSubscribedError: (error: unknown) =>
error instanceof Error && error.name === "ClineNotSubscribedError",
@@ -53,15 +39,6 @@ vi.mock("@cline/core", () => ({
text
.toLowerCase()
.includes("the user is not subscribed to required model plan"),
isClineOrgIndividualInferenceSubscriptionError: (error: unknown) =>
error instanceof Error &&
error.name === "ClineOrgIndividualInferenceSubscriptionError",
isClineOrgIndividualInferenceSubscriptionMessage: (text: string) =>
text
.toLowerCase()
.includes(
"organization accounts cannot use individual model inference subscriptions",
),
prewarmFileIndex: vi.fn(async () => undefined),
SessionSource: {
CLI: "cli",
@@ -111,7 +88,7 @@ vi.mock("./prompt", () => ({
}));
vi.mock("./session-events", () => ({
subscribeToAgentEvents: sessionEventsMocks.subscribeToAgentEvents,
subscribeToAgentEvents: vi.fn(() => () => {}),
}));
describe("runAgent", () => {
@@ -135,9 +112,6 @@ describe("runAgent", () => {
outputMocks.writeln.mockReset();
outputMocks.emitJsonLine.mockReset();
outputMocks.setActiveCliSession.mockReset();
sessionEventsMocks.listener = undefined;
sessionEventsMocks.subscribeToAgentEvents.mockClear();
vi.unstubAllGlobals();
});
afterEach(() => {
@@ -964,121 +938,4 @@ describe("runAgent", () => {
expect.stringContaining("est. cost"),
);
});
it("zeros Cline free model costs in JSON results and agent events", async () => {
const startedAt = new Date("2026-03-22T00:00:00.000Z");
const endedAt = new Date("2026-03-22T00:00:01.000Z");
vi.stubGlobal(
"fetch",
vi.fn(async () => {
return new Response(
JSON.stringify({
free: [{ id: "deepseek/deepseek-v4-flash" }],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}),
);
sessionManagerMocks.start.mockResolvedValue({
sessionId: "session-1",
manifestPath: "/tmp/manifest.json",
messagesPath: "/tmp/messages.json",
manifest: {
session_id: "session-1",
},
result: {
text: "completed text",
usage: {
inputTokens: 1,
outputTokens: 1,
cacheReadTokens: 0,
cacheWriteTokens: 0,
totalCost: 0.25,
},
messages: [],
toolCalls: [],
iterations: 1,
finishReason: "completed",
model: {
id: "deepseek/deepseek-v4-flash",
provider: "cline",
info: {},
},
startedAt,
endedAt,
durationMs: 1000,
},
});
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue({
usage: {
inputTokens: 1,
outputTokens: 1,
cacheReadTokens: 0,
cacheWriteTokens: 0,
totalCost: 0.25,
},
aggregateUsage: {
inputTokens: 1,
outputTokens: 1,
cacheReadTokens: 0,
cacheWriteTokens: 0,
totalCost: 0.25,
},
});
const { runAgent } = await import("./run-agent");
const { handleEvent } = await import("../utils/events");
await expect(
runAgent("test prompt", {
baseUrl: "https://cline.test/api/v1",
cwd: process.cwd(),
enableAgentTeams: false,
enableSpawnAgent: false,
enableTools: [],
execution: {
maxConsecutiveMistakes: 3,
},
logger: undefined,
mode: "yolo",
modelId: "deepseek/deepseek-v4-flash",
outputMode: "json",
providerId: "cline",
systemPrompt: "system",
thinking: false,
toolPolicies: { "*": { autoApprove: true } },
verbose: false,
workspaceRoot: process.cwd(),
} as never),
).resolves.toBeUndefined();
const runResult = outputMocks.emitJsonLine.mock.calls.find(
([, payload]) =>
(payload as { type?: string } | undefined)?.type === "run_result",
)?.[1] as
| {
usage?: { totalCost?: number };
aggregateUsage?: { totalCost?: number };
}
| undefined;
expect(runResult?.usage?.totalCost).toBe(0);
expect(runResult?.aggregateUsage?.totalCost).toBe(0);
sessionEventsMocks.listener?.({
type: "usage",
inputTokens: 1,
outputTokens: 1,
cost: 0.25,
totalCost: 0.25,
});
expect(handleEvent).toHaveBeenLastCalledWith(
expect.objectContaining({
type: "usage",
cost: 0,
totalCost: 0,
}),
expect.any(Object),
);
});
});
+3 -16
View File
@@ -18,11 +18,6 @@ import {
} from "../utils/approval";
import { formatCliErrorMessage } from "../utils/cline-pass-errors";
import { handleEvent, handleTeamEvent } from "../utils/events";
import {
shouldZeroClineFreeModelCost,
zeroCliAgentEventCost,
zeroCliUsageCost,
} from "../utils/free-model-cost";
import { createRuntimeHooks } from "../utils/hooks";
import {
c,
@@ -189,10 +184,8 @@ export async function runAgent(
let reasoningChunkCount = 0;
let redactedReasoningChunkCount = 0;
const displayedErrorMessages = new Set<string>();
const shouldZeroCost = await shouldZeroClineFreeModelCost(config);
const onAgentEvent = (rawEvent: AgentEvent): void => {
const event = zeroCliAgentEventCost(rawEvent, shouldZeroCost);
const onAgentEvent = (event: AgentEvent): void => {
if (event.type === "content_start" && event.contentType === "reasoning") {
reasoningChunkCount += 1;
if (event.redacted) {
@@ -346,14 +339,8 @@ export async function runAgent(
const usageSummary = await sessionManager.getAccumulatedUsage(
started.sessionId,
);
const aggregateUsage = zeroCliUsageCost(
usageSummary?.aggregateUsage,
shouldZeroCost,
);
const usage = zeroCliUsageCost(
aggregateUsage ?? usageSummary?.usage ?? result.usage,
shouldZeroCost,
);
const aggregateUsage = usageSummary?.aggregateUsage;
const usage = aggregateUsage ?? usageSummary?.usage ?? result.usage;
if (config.outputMode === "json") {
emitJsonLine("stdout", {
+4 -18
View File
@@ -24,11 +24,6 @@ import { disableOpenTuiGraphicsProbe } from "../tui/opentui-env";
import type { QueuedPromptItem } from "../tui/types";
import { type ChatCommandState, chatCommandHost } from "../utils/chat-commands";
import { applyCliCompactionMode } from "../utils/compaction-mode";
import {
shouldZeroClineFreeModelCost,
zeroCliAgentEventCost,
zeroCliUsageCost,
} from "../utils/free-model-cost";
import {
prepareTerminalForPostTuiOutput,
writeErr,
@@ -158,7 +153,6 @@ export async function runInteractive(
askQuestionRef: tuiAskQuestion,
});
const providerSettingsManager = new ProviderSettingsManager();
let zeroCurrentTurnCost = false;
const sessionRuntime = createInteractiveSessionRuntime({
config,
@@ -172,7 +166,7 @@ export async function runInteractive(
resolveMistakeLimitDecision,
switchToActModeTool,
onAgentEvent: (event) => {
uiEvents.emit("agent", zeroCliAgentEventCost(event, zeroCurrentTurnCost));
uiEvents.emit("agent", event);
},
onTeamEvent: (event) => {
uiEvents.emit("team", event);
@@ -438,7 +432,6 @@ export async function runInteractive(
},
onSubmit: async (input, mode, delivery, attachments, onCommandOutput) => {
let commandOutput: string | undefined;
let zeroTurnCost = false;
try {
await sessionRuntime.ensureReady();
await waitForSubmittedMode(mode);
@@ -485,8 +478,6 @@ export async function runInteractive(
}
input = chatCommandResult.input;
commandOutput = chatCommandResult.commandOutput;
zeroTurnCost = await shouldZeroClineFreeModelCost(config);
zeroCurrentTurnCost = zeroTurnCost;
const {
prompt: userInput,
userImages,
@@ -528,9 +519,8 @@ export async function runInteractive(
}
if (result.finishReason !== "completed") {
if (result.finishReason === "aborted" || isAbortInProgress()) {
const usage = zeroCliUsageCost(
await sessionRuntime.getAccumulatedUsage(result.usage),
zeroTurnCost,
const usage = await sessionRuntime.getAccumulatedUsage(
result.usage,
);
return {
usage,
@@ -545,10 +535,7 @@ export async function runInteractive(
errorText || `Turn finished with ${result.finishReason}`,
);
}
const usage = zeroCliUsageCost(
await sessionRuntime.getAccumulatedUsage(result.usage),
zeroTurnCost,
);
const usage = await sessionRuntime.getAccumulatedUsage(result.usage);
return {
usage,
currentContextSize: getCurrentContextSize(result.messages),
@@ -572,7 +559,6 @@ export async function runInteractive(
});
throw error;
} finally {
zeroCurrentTurnCost = false;
if (!delivery) {
isRunning = false;
clearAbortInProgress();
@@ -3,9 +3,7 @@ import type React from "react";
import { useState } from "react";
import "opentui-spinner/react";
import {
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassSubscriptionUrl,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassSubscriptionError,
} from "../../utils/cline-pass-errors";
import {
@@ -331,30 +329,6 @@ function ClinePassSubscriptionErrorView(props: { defaultFg?: string }) {
);
}
function ClineOrgIndividualInferenceSubscriptionErrorView(props: {
defaultFg?: string;
}) {
return (
<box flexDirection="row">
<text fg="yellow" content="* " />
<box
flexDirection="column"
border
borderStyle="rounded"
borderColor="yellow"
paddingX={1}
>
<text fg="yellow">Personal ClinePass required</text>
<text
fg={props.defaultFg}
selectable
content={getClineOrgIndividualInferenceSubscriptionMessage()}
/>
</box>
</box>
);
}
export function ChatEntryView(props: {
entry: ChatEntry;
accent?: string;
@@ -453,11 +427,6 @@ export function ChatEntryView(props: {
if (isClineAccountCreditsErrorMessage(entry.text)) {
return <ClineCreditsErrorView defaultFg={defaultFg} />;
}
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(entry.text)) {
return (
<ClineOrgIndividualInferenceSubscriptionErrorView defaultFg={defaultFg} />
);
}
if (isClinePassSubscriptionError(entry.text)) {
return <ClinePassSubscriptionErrorView defaultFg={defaultFg} />;
}
@@ -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)}`;
}
+1 -19
View File
@@ -1,9 +1,7 @@
import { describe, expect, it } from "vitest";
import {
formatCliErrorMessage,
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassSubscriptionUrl,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassSubscriptionError,
} from "./cline-pass-errors";
@@ -22,23 +20,7 @@ describe("cline-pass-errors", () => {
it("formats the ClinePass subscription URL", () => {
expect(getClinePassSubscriptionUrl()).toBe(
"https://app.cline.bot/dashboard/subscription?personal=true",
"https://app.cline.bot/dashboard/subscription/",
);
});
it("recognizes and formats organization account individual subscription errors", () => {
const raw =
"403 Error 403: organization accounts cannot use individual model inference subscriptions";
const formatted = getClineOrgIndividualInferenceSubscriptionMessage();
expect(isClineOrgIndividualInferenceSubscriptionErrorMessage(raw)).toBe(
true,
);
expect(
isClineOrgIndividualInferenceSubscriptionErrorMessage(
new Error(formatted),
),
).toBe(true);
expect(formatCliErrorMessage(new Error(raw))).toBe(formatted);
});
});
+1 -30
View File
@@ -1,16 +1,10 @@
import {
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassSubscriptionUrl,
isClineNotSubscribedError,
isClineNotSubscribedMessage,
isClineOrgIndividualInferenceSubscriptionError,
isClineOrgIndividualInferenceSubscriptionMessage,
} from "@cline/core";
export {
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassSubscriptionUrl,
};
export { getClinePassSubscriptionUrl };
function isFormattedClinePassSubscriptionMessage(message: string): boolean {
const normalized = message.trim().toLowerCase();
@@ -38,30 +32,7 @@ export function isClinePassSubscriptionError(error: unknown): boolean {
);
}
export function isClineOrgIndividualInferenceSubscriptionErrorMessage(
error: unknown,
): boolean {
if (isClineOrgIndividualInferenceSubscriptionError(error)) {
return true;
}
if (error instanceof Error) {
return (
error.name === "ClineOrgIndividualInferenceSubscriptionError" ||
isClineOrgIndividualInferenceSubscriptionMessage(error.message) ||
error.message === getClineOrgIndividualInferenceSubscriptionMessage()
);
}
return (
typeof error === "string" &&
(isClineOrgIndividualInferenceSubscriptionMessage(error) ||
error === getClineOrgIndividualInferenceSubscriptionMessage())
);
}
export function formatCliErrorMessage(error: unknown): string {
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(error)) {
return getClineOrgIndividualInferenceSubscriptionMessage();
}
if (error instanceof Error) {
return error.message;
}
-162
View File
@@ -1,162 +0,0 @@
import type { AgentEvent } from "@cline/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
clearClineFreeModelCostCache,
shouldZeroClineFreeModelCost,
zeroCliAgentEventCost,
zeroCliUsageCost,
} from "./free-model-cost";
afterEach(() => {
clearClineFreeModelCostCache();
vi.unstubAllGlobals();
});
describe("shouldZeroClineFreeModelCost", () => {
it("uses the Cline free model list", async () => {
const fetchMock = vi.fn(
async (_input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
return new Response(
JSON.stringify({
free: [{ id: "deepseek/deepseek-v4-flash" }],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
},
);
vi.stubGlobal("fetch", fetchMock);
await expect(
shouldZeroClineFreeModelCost({
providerId: "cline",
modelId: "deepseek/deepseek-v4-flash",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(true);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
"https://cline.test/api/v1/ai/cline/recommended-models",
);
});
it("does not zero non-Cline providers", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(
shouldZeroClineFreeModelCost({
providerId: "openrouter",
modelId: "deepseek/deepseek-v4-flash",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});
it("does not match a paid model by only the final path segment", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
return new Response(
JSON.stringify({
free: [{ id: "deepseek/deepseek-v4-flash" }],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}),
);
await expect(
shouldZeroClineFreeModelCost({
providerId: "cline",
modelId: "acme/deepseek-v4-flash",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(false);
});
it("retries after a failed free model list fetch", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response("unavailable", { status: 503 }))
.mockResolvedValueOnce(
new Response(
JSON.stringify({
free: [{ id: "deepseek/deepseek-v4-flash" }],
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
vi.stubGlobal("fetch", fetchMock);
await expect(
shouldZeroClineFreeModelCost({
providerId: "cline",
modelId: "deepseek/deepseek-v4-flash",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(false);
await expect(
shouldZeroClineFreeModelCost({
providerId: "cline",
modelId: "deepseek/deepseek-v4-flash",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
describe("zeroCliUsageCost", () => {
it("zeros total cost while preserving token usage", () => {
expect(
zeroCliUsageCost(
{
inputTokens: 10,
outputTokens: 5,
totalCost: 0.001,
},
true,
),
).toEqual({
inputTokens: 10,
outputTokens: 5,
totalCost: 0,
});
});
});
describe("zeroCliAgentEventCost", () => {
it("zeros usage event cost fields", () => {
const event = {
type: "usage",
inputTokens: 10,
outputTokens: 5,
cost: 0.001,
totalCost: 0.001,
} as AgentEvent;
expect(zeroCliAgentEventCost(event, true)).toMatchObject({
cost: 0,
totalCost: 0,
});
});
it("zeros done event usage cost", () => {
const event = {
type: "done",
reason: "completed",
text: "ok",
iterations: 1,
usage: {
inputTokens: 10,
outputTokens: 5,
totalCost: 0.001,
},
} as AgentEvent;
expect(zeroCliAgentEventCost(event, true)).toMatchObject({
usage: { totalCost: 0 },
});
});
});
-123
View File
@@ -1,123 +0,0 @@
import type { AgentEvent } from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import type { Config } from "./types";
const CLINE_RECOMMENDED_MODELS_TIMEOUT_MS = 5_000;
const freeModelIdsByBaseUrl = new Map<
string,
Promise<readonly string[] | undefined>
>();
function normalizeModelId(modelId: string | undefined): string {
return modelId?.trim().toLowerCase() ?? "";
}
function modelIdsMatch(selectedModelId: string, freeModelId: string): boolean {
const selected = normalizeModelId(selectedModelId);
const free = normalizeModelId(freeModelId);
if (!selected || !free) return false;
return selected === free;
}
function resolveClineRecommendedModelsUrl(baseUrl: string): string {
const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
const apiBaseUrl = normalizedBaseUrl.endsWith("/api/v1")
? normalizedBaseUrl.slice(0, -"/api/v1".length)
: normalizedBaseUrl;
return `${apiBaseUrl}/api/v1/ai/cline/recommended-models`;
}
async function fetchClineFreeModelIds(
baseUrl: string,
): Promise<readonly string[] | undefined> {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
CLINE_RECOMMENDED_MODELS_TIMEOUT_MS,
);
try {
const response = await fetch(resolveClineRecommendedModelsUrl(baseUrl), {
signal: controller.signal,
});
if (!response.ok) return undefined;
const json = (await response.json()) as { free?: unknown };
return Array.isArray(json.free)
? json.free
.map((model) =>
model && typeof model === "object"
? (model as Record<string, unknown>).id
: undefined,
)
.filter((id): id is string => typeof id === "string" && id.length > 0)
: [];
} catch {
return undefined;
} finally {
clearTimeout(timeout);
}
}
function getClineFreeModelIds(baseUrl: string): Promise<readonly string[]> {
const cacheKey = baseUrl.trim();
let cached = freeModelIdsByBaseUrl.get(cacheKey);
if (!cached) {
cached = fetchClineFreeModelIds(cacheKey).then((ids) => {
if (!ids) freeModelIdsByBaseUrl.delete(cacheKey);
return ids;
});
freeModelIdsByBaseUrl.set(cacheKey, cached);
}
return cached.then((ids) => ids ?? []);
}
export async function shouldZeroClineFreeModelCost(
config: Pick<Config, "providerId" | "modelId" | "baseUrl">,
): Promise<boolean> {
if (config.providerId !== "cline") return false;
const modelId = normalizeModelId(config.modelId);
if (!modelId) return false;
const baseUrl =
config.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl;
const freeModelIds = await getClineFreeModelIds(baseUrl);
return freeModelIds.some((freeModelId) =>
modelIdsMatch(modelId, freeModelId),
);
}
export function zeroCliUsageCost<T extends { totalCost?: number } | undefined>(
usage: T,
shouldZeroCost: boolean,
): T {
if (
!shouldZeroCost ||
!usage ||
typeof usage.totalCost !== "number" ||
usage.totalCost === 0
) {
return usage;
}
return { ...usage, totalCost: 0 } as T;
}
export function zeroCliAgentEventCost(
event: AgentEvent,
shouldZeroCost: boolean,
): AgentEvent {
if (!shouldZeroCost) return event;
if (event.type === "done" && event.usage) {
return {
...event,
usage: zeroCliUsageCost(event.usage, true),
};
}
if (event.type !== "usage") return event;
const next = { ...event } as Record<string, unknown>;
if (typeof next.cost === "number") next.cost = 0;
if (typeof next.totalCost === "number") next.totalCost = 0;
return next as unknown as AgentEvent;
}
export function clearClineFreeModelCostCache(): void {
freeModelIdsByBaseUrl.clear();
}
+323 -14
View File
@@ -1,16 +1,325 @@
import {
CONNECTOR_PLATFORMS,
shouldIncludeConnectorField,
} from "@cline/shared";
export interface PlatformDef {
id: string;
name: string;
type: "polling" | "webhook" | "hybrid";
hint: string;
fields: FieldDef[];
security?: SecurityDef;
}
export type {
ConnectorFieldCondition as FieldCondition,
ConnectorFieldDef as FieldDef,
ConnectorPlatformDef as PlatformDef,
ConnectorSecurityDef as SecurityDef,
ConnectorSecurityFieldDef as SecurityFieldDef,
} from "@cline/shared";
export { CONNECTOR_PLATFORMS, shouldIncludeConnectorField };
export interface FieldDef {
flag: string;
label: string;
placeholder?: string;
required?: boolean;
help?: string[];
initialValue?: string;
options?: Array<{ value: string; label: string; hint?: string }>;
includeWhen?: FieldCondition;
}
export const PLATFORMS = CONNECTOR_PLATFORMS;
export const shouldIncludeField = shouldIncludeConnectorField;
export type FieldCondition = {
flag: string;
equals?: string;
notEquals?: string;
};
export interface SecurityFieldDef {
key: string;
label: string;
placeholder?: string;
help?: string[];
requiredMessage: string;
validate?: (value: string) => string | undefined;
}
export interface SecurityDef {
prompt: string;
fields: SecurityFieldDef[];
buildArgs: (values: Record<string, string>) => string[];
}
export function shouldIncludeField(
field: FieldDef,
values: Record<string, string>,
): boolean {
const condition = field.includeWhen;
if (!condition) {
return true;
}
const value = values[condition.flag] ?? "";
if (condition.equals !== undefined && value !== condition.equals) {
return false;
}
if (condition.notEquals !== undefined && value === condition.notEquals) {
return false;
}
return true;
}
function validateTelegramUserId(value: string): string | undefined {
return /^\d+$/.test(value)
? undefined
: "Telegram user ID must contain digits only";
}
function validateSlackTeamId(value: string): string | undefined {
return /^T[A-Z0-9]+$/.test(value)
? undefined
: "Slack workspace ID must start with T and contain uppercase letters or digits only";
}
function validateSlackUserId(value: string): string | undefined {
return /^[UW][A-Z0-9]+$/.test(value)
? undefined
: "Slack member ID must start with U or W and contain uppercase letters or digits only";
}
export const PLATFORMS: PlatformDef[] = [
{
id: "telegram",
name: "Telegram",
type: "polling",
hint: "Easiest to set up. No public URL needed.",
fields: [
{
flag: "-k",
label: "Bot token",
placeholder: "7123456789:AAH...",
required: true,
help: [
"Open Telegram and start a chat with @BotFather",
"Send /newbot and follow the prompts",
"BotFather gives you this after creating the bot",
"It looks like 7123456789:AAHxxx...",
],
},
],
security: {
prompt:
"By default, anyone who finds your bot can message it and run tasks on your machine. Restrict access to your Telegram user ID?",
fields: [
{
key: "userId",
label: "Your Telegram user ID",
placeholder: "123456789",
help: [
"Message @userinfobot on Telegram",
"It will reply with your numeric user ID",
],
requiredMessage: "User ID is required to restrict access",
validate: validateTelegramUserId,
},
],
buildArgs: ({ userId }) => ["--allowed-user-id", userId ?? ""],
},
},
{
id: "slack",
name: "Slack",
type: "hybrid",
hint: "Public URL for webhook mode; leave blank for socket mode.",
fields: [
{
flag: "--bot-token",
label: "Bot token",
placeholder: "xoxb-...",
required: true,
help: [
"Go to api.slack.com/apps and create a new app",
"Add Bot Token Scopes: chat:write, app_mentions:read, channels:history, channels:read, im:history, im:read, im:write, users:read",
"Install to workspace and copy the Bot Token",
],
},
{
flag: "--base-url",
label: "Public base URL",
placeholder: "leave blank for socket mode",
help: [
"Enter a publicly accessible URL for webhook mode",
"Leave blank to use Slack socket mode instead",
],
},
{
flag: "--signing-secret",
label: "Signing secret",
required: true,
help: ["Found in your app's Basic Information page"],
includeWhen: { flag: "--base-url", notEquals: "" },
},
{
flag: "--app-token",
label: "App-level token",
placeholder: "xapp-...",
required: true,
help: [
"Enable Socket Mode in the Slack app",
"Generate an app-level token with the connections:write scope",
],
includeWhen: { flag: "--base-url", equals: "" },
},
],
security: {
prompt: "Restrict which Slack users can interact with the bot?",
fields: [
{
key: "teamId",
label: "Allowed Slack workspace ID",
placeholder: "T01ABC123",
help: [
"Open your Slack workspace URL in a browser",
"The workspace ID is the segment after /client/, for example T01ABC123",
],
requiredMessage: "Workspace ID is required to restrict access",
validate: validateSlackTeamId,
},
{
key: "userId",
label: "Allowed Slack member ID",
placeholder: "U01ABC123",
help: [
"Click a user's name in Slack, then View full profile",
"Click ... and Copy member ID",
],
requiredMessage: "Member ID is required to restrict access",
validate: validateSlackUserId,
},
],
buildArgs: ({ teamId, userId }) => [
"--hook-command",
`jq -r ".payload.actor.participantKey" | grep -qx "slack:team:${teamId}:user:${userId}" && echo '{"action":"allow"}' || echo '{"action":"deny"}'`,
],
},
},
{
id: "discord",
name: "Discord",
type: "webhook",
hint: "Requires a Discord app and public URL.",
fields: [
{
flag: "--application-id",
label: "Application ID",
required: true,
help: [
"Go to discord.com/developers/applications",
"Create a new app, copy the Application ID",
],
},
{
flag: "--bot-token",
label: "Bot token",
required: true,
help: ["Go to Bot section, create a bot, copy the token"],
},
{
flag: "--public-key",
label: "Public key",
required: true,
help: ["Found in General Information of your app"],
},
{
flag: "--base-url",
label: "Public base URL",
placeholder: "https://example.com",
required: true,
help: [
"Base URL for the connector",
"For Discord, set the Interactions Endpoint URL to <base-url>/api/webhooks/discord",
],
},
],
},
{
id: "whatsapp",
name: "WhatsApp",
type: "webhook",
hint: "Requires Meta developer account and public URL.",
fields: [
{
flag: "--phone-number-id",
label: "Phone number ID",
required: true,
help: ["From your WhatsApp Business account in Meta Developer portal"],
},
{
flag: "--access-token",
label: "Access token",
required: true,
help: ["Generate a permanent token in Meta Developer portal"],
},
{
flag: "--app-secret",
label: "App secret",
required: true,
help: ["Found in App Settings > Basic"],
},
{
flag: "--verify-token",
label: "Webhook verify token",
placeholder: "my-verify-token",
required: true,
help: ["Any string you choose, used to verify webhook setup"],
},
{
flag: "--base-url",
label: "Public base URL",
placeholder: "https://example.com",
required: true,
},
],
},
{
id: "gchat",
name: "Google Chat",
type: "webhook",
hint: "Requires Google Cloud project and public URL.",
fields: [
{
flag: "--credentials-json",
label: "Service account credentials JSON",
required: true,
help: [
"Create a service account in Google Cloud Console",
"Download the credentials JSON file",
"Paste the JSON content here",
],
},
{
flag: "--base-url",
label: "Public base URL",
placeholder: "https://example.com",
required: true,
},
],
},
{
id: "linear",
name: "Linear",
type: "webhook",
hint: "React to Linear issues and comments.",
fields: [
{
flag: "--api-key",
label: "API key",
required: true,
help: ["Go to Linear Settings > API > Personal API keys"],
},
{
flag: "--webhook-secret",
label: "Webhook signing secret",
required: true,
help: [
"Go to Settings > API > Webhooks, create one",
"Copy the signing secret",
],
},
{
flag: "--base-url",
label: "Public base URL",
placeholder: "https://example.com",
required: true,
},
],
},
];
+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)
);
}
@@ -1,97 +0,0 @@
import { describe, expect, it } from "vitest";
import { __test__ } from "./connectors";
describe("connector launch command", () => {
it("uses Bun conditions when launching the source CLI from Bun", () => {
expect(
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
execPath: "/Users/test/.bun/bin/bun",
cliPath: "/repo/apps/cli/src/index.ts",
exists: () => true,
}),
).toEqual({
launcher: "/Users/test/.bun/bin/bun",
childArgs: [
"--conditions=development",
"/repo/apps/cli/src/index.ts",
"connect",
"telegram",
"--bot-token",
"token",
],
});
});
it("uses compiled CLI subcommands without Bun flags", () => {
expect(
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
execPath: "/Applications/Cline/bin/cline",
cliPath: "/repo/apps/cli/src/index.ts",
exists: () => true,
}),
).toEqual({
launcher: "/Applications/Cline/bin/cline",
childArgs: ["connect", "telegram", "--bot-token", "token"],
});
});
it("uses Bun conditions when launching the source CLI from Node", () => {
expect(
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
execPath: "/usr/local/bin/node",
cliPath: "/repo/apps/cli/src/index.ts",
exists: () => true,
}),
).toEqual({
launcher: "bun",
childArgs: [
"--conditions=development",
"/repo/apps/cli/src/index.ts",
"connect",
"telegram",
"--bot-token",
"token",
],
});
});
it("detects Windows Node when launching the source CLI", () => {
expect(
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
execPath: "node.exe",
cliPath: "C:\\repo\\apps\\cli\\src\\index.ts",
exists: () => true,
}),
).toEqual({
launcher: "bun",
childArgs: [
"--conditions=development",
"C:\\repo\\apps\\cli\\src\\index.ts",
"connect",
"telegram",
"--bot-token",
"token",
],
});
});
it("strips terminal color codes from connector command failures", () => {
expect(
__test__.normalizeConnectorError(
"\u001B[31merror:\u001B[0m error: unknown option '--conditions=development'",
"connector start failed",
),
).toBe("unknown option '--conditions=development'");
});
it("turns Telegram unauthorized responses into a token validation message", () => {
expect(
__test__.normalizeConnectorError(
"\u001B[31merror:\u001B[0m Telegram getMe failed (401 Unauthorized): Unauthorized",
"connector start failed",
),
).toBe(
"Telegram rejected this bot token. Copy the token from @BotFather and try again.",
);
});
});
+23 -86
View File
@@ -1,8 +1,5 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { basename } from "node:path";
import process from "node:process";
import { withResolvedClineBuildEnv } from "@cline/shared";
import { listConnectorCatalog } from "../../../cli/src/connectors/catalog";
import { listActiveConnectors } from "../../../cli/src/connectors/status";
import {
@@ -16,68 +13,6 @@ import type {
import { cliIndexPath, workspaceRoot } from "./deps";
import { asRecord, asString } from "./utils";
type CliConnectCommand = {
launcher: string;
childArgs: string[];
};
const ANSI_ESCAPE_PATTERN = new RegExp(
[
"[\\u001B\\u009B][[\\]()#;?]*",
"(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\\u0007)",
"|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))",
].join(""),
"g",
);
function stripAnsi(value: string): string {
return value.replace(ANSI_ESCAPE_PATTERN, "");
}
function normalizeConnectorError(rawMessage: string, fallback: string): string {
const message =
stripAnsi(rawMessage)
.replace(/\r\n/g, "\n")
.trim()
.replace(/^(?:error:\s*)+/i, "")
.trim() || fallback;
if (
/^Telegram getMe failed \(401 Unauthorized\): Unauthorized$/i.test(message)
) {
return "Telegram rejected this bot token. Copy the token from @BotFather and try again.";
}
return message.slice(0, 2_000);
}
function buildCliConnectCommand(
args: string[],
options: {
execPath?: string;
cliPath?: string;
exists?: (path: string) => boolean;
} = {},
): CliConnectCommand {
const execPath = options.execPath ?? process.execPath;
const cliPath = options.cliPath ?? cliIndexPath;
const exists = options.exists ?? existsSync;
const runtimeName = basename(execPath).toLowerCase();
const isBunRuntime = runtimeName.includes("bun");
const isNodeRuntime = runtimeName === "node" || runtimeName === "node.exe";
const useBunSourceEntrypoint =
(isBunRuntime || isNodeRuntime) && exists(cliPath);
const launcher = isBunRuntime
? execPath
: useBunSourceEntrypoint
? "bun"
: execPath;
const childArgs = useBunSourceEntrypoint
? ["--conditions=development", cliPath, "connect", ...args]
: ["connect", ...args];
return { launcher, childArgs };
}
export function connectorChannelsPayload(): WebviewConnectorChannelsResponse {
const supported = new Set(
listConnectorCatalog().map((connector) => connector.name),
@@ -120,14 +55,23 @@ async function runCliConnectCommand(args: string[]): Promise<{
stdout: string;
stderr: string;
}> {
const { launcher, childArgs } = buildCliConnectCommand(args);
const child = spawn(launcher, childArgs, {
cwd: workspaceRoot,
env: withResolvedClineBuildEnv(process.env),
stdio: ["ignore", "pipe", "pipe"],
// Prevent a console window from flashing on Windows.
windowsHide: true,
});
const launcher = (process.versions as Record<string, string | undefined>).bun
? process.execPath
: "bun";
const child = spawn(
launcher,
["--conditions=development", cliIndexPath, "connect", ...args],
{
cwd: workspaceRoot,
env: {
...process.env,
CLINE_BUILD_ENV: process.env.CLINE_BUILD_ENV ?? "development",
},
stdio: ["ignore", "pipe", "pipe"],
// Prevent a console window from flashing on Windows.
windowsHide: true,
},
);
let stdout = "";
let stderr = "";
child.stdout?.setEncoding("utf8");
@@ -213,10 +157,9 @@ export async function startConnectorChannel(
const result = await runCliConnectCommand(cliArgs);
if (result.code !== 0) {
throw new Error(
normalizeConnectorError(
result.stderr || result.stdout,
"connector start failed",
),
(result.stderr.trim() || result.stdout.trim() || "connector start failed")
.trim()
.slice(0, 2_000),
);
}
await waitForConnectorState(() =>
@@ -225,11 +168,6 @@ export async function startConnectorChannel(
return connectorChannelsPayload();
}
export const __test__ = {
buildCliConnectCommand,
normalizeConnectorError,
};
export async function stopConnectorChannel(
args?: Record<string, unknown>,
): Promise<WebviewConnectorChannelsResponse> {
@@ -244,10 +182,9 @@ export async function stopConnectorChannel(
const result = await runCliConnectCommand([channel, "--stop"]);
if (result.code !== 0) {
throw new Error(
normalizeConnectorError(
result.stderr || result.stdout,
"connector stop failed",
),
(result.stderr.trim() || result.stdout.trim() || "connector stop failed")
.trim()
.slice(0, 2_000),
);
}
await waitForConnectorState(
-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",
+3 -3
View File
@@ -13,9 +13,10 @@
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@rive-app/react-webgl2": "^4.27.2",
"@shikijs/langs": "^4.2.0",
"@shikijs/themes": "^4.2.0",
"@streamdown/cjk": "^1.0.3",
"@streamdown/code": "^1.1.1",
"@streamdown/math": "^1.0.2",
"@streamdown/mermaid": "^1.0.2",
"@tailwindcss/vite": "^4.2.1",
"@xyflow/react": "^12.10.1",
"ai": "^6.0.116",
@@ -26,7 +27,6 @@
"embla-carousel-react": "^8.6.0",
"lucide-react": "^0.577.0",
"media-chrome": "^4.18.1",
"mermaid": "^11.15.0",
"motion": "^12.38.0",
"nanoid": "^5.1.7",
"next-themes": "^0.4.6",
+11 -31
View File
@@ -22,14 +22,7 @@ import {
WrenchIcon,
} from "lucide-react";
import type { ReactNode } from "react";
import {
lazy,
Suspense,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
@@ -60,24 +53,19 @@ import type {
WebviewOutboundMessage,
WebviewSessionSummary,
} from "../../webview-protocol";
import Chat from "./Chat";
import { PageFrame, PageHeader } from "./components/views/page-layout";
import type { CustomizationSection } from "./components/views/settings/extensions-view";
import type { SettingsSection } from "./components/views/settings/settings-view";
import {
type CustomizationSection,
CustomizationSectionView,
} from "./components/views/settings/extensions-view";
import {
type SettingsSection,
SettingsView,
} from "./components/views/settings/settings-view";
import { syncHubTheme } from "./lib/theme";
import { postToHost } from "./vscode";
const Chat = lazy(() => import("./Chat"));
const SettingsView = lazy(() =>
import("./components/views/settings/settings-view").then((module) => ({
default: module.SettingsView,
})),
);
const CustomizationSectionView = lazy(() =>
import("./components/views/settings/extensions-view").then((module) => ({
default: module.CustomizationSectionView,
})),
);
type View =
| "home"
| "sessions"
@@ -250,14 +238,6 @@ function currentPathWithSearch(): string {
return `${window.location.pathname}${window.location.search}`;
}
function ViewLoading() {
return (
<PageFrame>
<p className="text-sm text-muted-foreground">Loading...</p>
</PageFrame>
);
}
function formatRelativeTime(timestamp?: number): string {
if (!timestamp) return "unknown";
const elapsed = Math.max(0, Date.now() - timestamp);
@@ -1353,7 +1333,7 @@ function App() {
return (
<Shell onNavigate={navigate} version={hubState.coreVersion} view={view}>
<Suspense fallback={<ViewLoading />}>{content}</Suspense>
{content}
</Shell>
);
}
@@ -11,13 +11,12 @@ import {
useState,
} from "react";
import type {
HighlighterCore,
LanguageRegistration,
BundledLanguage,
BundledTheme,
HighlighterGeneric,
ThemedToken,
ThemeRegistration,
} from "shiki/core";
import { createHighlighterCore } from "shiki/core";
import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
} from "shiki";
import { createHighlighter } from "shiki";
import { Button } from "@/components/ui/button";
import {
Select,
@@ -37,78 +36,6 @@ const isUnderline = (fontStyle: number | undefined) =>
// oxlint-disable-next-line eslint(no-bitwise)
fontStyle && fontStyle & 4;
const SUPPORTED_LANGUAGES = [
"bash",
"css",
"diff",
"html",
"javascript",
"json",
"jsonc",
"jsx",
"markdown",
"python",
"shellscript",
"tsx",
"typescript",
"yaml",
] as const;
export type SupportedCodeLanguage = (typeof SUPPORTED_LANGUAGES)[number];
const SUPPORTED_LANGUAGE_SET = new Set<string>(SUPPORTED_LANGUAGES);
const LANGUAGE_LOADERS: Record<
SupportedCodeLanguage,
() => Promise<LanguageRegistration[]>
> = {
bash: () => import("@shikijs/langs/bash").then((module) => module.default),
css: () => import("@shikijs/langs/css").then((module) => module.default),
diff: () => import("@shikijs/langs/diff").then((module) => module.default),
html: () => import("@shikijs/langs/html").then((module) => module.default),
javascript: () =>
import("@shikijs/langs/javascript").then((module) => module.default),
json: () => import("@shikijs/langs/json").then((module) => module.default),
jsonc: () => import("@shikijs/langs/jsonc").then((module) => module.default),
jsx: () => import("@shikijs/langs/jsx").then((module) => module.default),
markdown: () =>
import("@shikijs/langs/markdown").then((module) => module.default),
python: () =>
import("@shikijs/langs/python").then((module) => module.default),
shellscript: () =>
import("@shikijs/langs/shellscript").then((module) => module.default),
tsx: () => import("@shikijs/langs/tsx").then((module) => module.default),
typescript: () =>
import("@shikijs/langs/typescript").then((module) => module.default),
yaml: () => import("@shikijs/langs/yaml").then((module) => module.default),
};
const LANGUAGE_ALIASES: Record<string, SupportedCodeLanguage> = {
console: "shellscript",
cjs: "javascript",
htm: "html",
js: "javascript",
json5: "jsonc",
md: "markdown",
mjs: "javascript",
py: "python",
sh: "shellscript",
shell: "shellscript",
ts: "typescript",
yml: "yaml",
};
const normalizeLanguage = (
language: string,
): SupportedCodeLanguage | "text" => {
const normalized = language.trim().toLowerCase();
if (!normalized) {
return "text";
}
const aliased = LANGUAGE_ALIASES[normalized] ?? normalized;
return SUPPORTED_LANGUAGE_SET.has(aliased) ? aliased : "text";
};
// Transform tokens to include pre-computed keys to avoid noArrayIndexKey lint
interface KeyedToken {
token: ThemedToken;
@@ -181,7 +108,7 @@ const LineSpan = ({
// Types
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string;
language: string;
language: BundledLanguage;
showLineNumbers?: boolean;
};
@@ -201,9 +128,10 @@ const CodeBlockContext = createContext<CodeBlockContextType>({
});
// Highlighter cache (singleton per language)
let highlighterPromise: Promise<HighlighterCore> | undefined;
let themesPromise: Promise<void> | undefined;
const languagePromises = new Map<SupportedCodeLanguage, Promise<void>>();
const highlighterCache = new Map<
string,
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
>();
// Token cache
const tokensCache = new Map<string, TokenizedCode>();
@@ -211,44 +139,27 @@ const tokensCache = new Map<string, TokenizedCode>();
// Subscribers for async token updates
const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>();
const getTokensCacheKey = (code: string, language: string) => {
const getTokensCacheKey = (code: string, language: BundledLanguage) => {
const start = code.slice(0, 100);
const end = code.length > 100 ? code.slice(-100) : "";
return `${language}:${code.length}:${start}:${end}`;
};
const getHighlighter = (): Promise<HighlighterCore> => {
if (!highlighterPromise) {
highlighterPromise = createHighlighterCore({
engine: createJavaScriptRegexEngine({ forgiving: true }),
});
}
return highlighterPromise;
};
const ensureThemes = (highlighter: HighlighterCore): Promise<void> => {
if (!themesPromise) {
themesPromise = Promise.all([
import("@shikijs/themes/github-light").then((module) => module.default),
import("@shikijs/themes/github-dark").then((module) => module.default),
]).then((themes: ThemeRegistration[]) => highlighter.loadTheme(...themes));
}
return themesPromise;
};
const ensureLanguage = (
highlighter: HighlighterCore,
language: SupportedCodeLanguage,
): Promise<void> => {
const cached = languagePromises.get(language);
const getHighlighter = (
language: BundledLanguage,
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
const cached = highlighterCache.get(language);
if (cached) {
return cached;
}
const languagePromise = LANGUAGE_LOADERS[language]().then((registrations) =>
highlighter.loadLanguage(...registrations),
);
languagePromises.set(language, languagePromise);
return languagePromise;
const highlighterPromise = createHighlighter({
langs: [language],
themes: ["github-light", "github-dark"],
});
highlighterCache.set(language, highlighterPromise);
return highlighterPromise;
};
// Create raw tokens for immediate display while highlighting loads
@@ -270,16 +181,11 @@ const createRawTokens = (code: string): TokenizedCode => ({
// Synchronous highlight with callback for async results
export const highlightCode = (
code: string,
language: string,
language: BundledLanguage,
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
callback?: (result: TokenizedCode) => void,
): TokenizedCode | null => {
const langToUse = normalizeLanguage(language);
if (langToUse === "text") {
return createRawTokens(code);
}
const tokensCacheKey = getTokensCacheKey(code, langToUse);
const tokensCacheKey = getTokensCacheKey(code, language);
// Return cached result if available
const cached = tokensCache.get(tokensCacheKey);
@@ -296,11 +202,11 @@ export const highlightCode = (
}
// Start highlighting in background - fire-and-forget async pattern
getHighlighter()
getHighlighter(language)
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then)
.then(async (highlighter) => {
await ensureThemes(highlighter);
await ensureLanguage(highlighter, langToUse);
.then((highlighter) => {
const availableLangs = highlighter.getLoadedLanguages();
const langToUse = availableLangs.includes(language) ? language : "text";
const result = highlighter.codeToTokens(code, {
lang: langToUse,
@@ -470,7 +376,7 @@ export const CodeBlockContent = ({
showLineNumbers = false,
}: {
code: string;
language: string;
language: BundledLanguage;
showLineNumbers?: boolean;
}) => {
// Memoized raw tokens for immediate display
@@ -1,5 +1,9 @@
"use client";
import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import type { UIMessage } from "ai";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
@@ -12,6 +16,7 @@ import {
useMemo,
useState,
} from "react";
import { Streamdown } from "streamdown";
import { Button } from "@/components/ui/button";
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group";
import {
@@ -21,7 +26,6 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { HubStreamdown } from "./streamdown";
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
@@ -312,18 +316,24 @@ export const MessageBranchPage = ({
);
};
export type MessageResponseProps = ComponentProps<typeof HubStreamdown>;
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
const streamdownPlugins = { cjk, code, math, mermaid };
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<HubStreamdown
<Streamdown
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className,
)}
plugins={streamdownPlugins}
{...props}
/>
),
(prevProps, nextProps) =>
prevProps.children === nextProps.children &&
nextProps.isAnimating === prevProps.isAnimating,
);
MessageResponse.displayName = "MessageResponse";
@@ -1,6 +1,10 @@
"use client";
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import { BrainIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import {
@@ -13,6 +17,7 @@ import {
useRef,
useState,
} from "react";
import { Streamdown } from "streamdown";
import {
Collapsible,
CollapsibleContent,
@@ -21,7 +26,6 @@ import {
import { cn } from "@/lib/utils";
import { Shimmer } from "./shimmer";
import { HubStreamdown } from "./streamdown";
interface ReasoningContextValue {
isStreaming: boolean;
@@ -200,6 +204,8 @@ export type ReasoningContentProps = ComponentProps<
children: string;
};
const streamdownPlugins = { cjk, code, math, mermaid };
export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent
@@ -210,7 +216,7 @@ export const ReasoningContent = memo(
)}
{...props}
>
<HubStreamdown>{children}</HubStreamdown>
<Streamdown plugins={streamdownPlugins}>{children}</Streamdown>
</CollapsibleContent>
),
);
@@ -1,170 +0,0 @@
import { cjk } from "@streamdown/cjk";
import type { MermaidConfig } from "mermaid";
import type { ComponentProps, ReactNode } from "react";
import { isValidElement, memo } from "react";
import {
type Components,
type DiagramPlugin,
Streamdown,
type StreamdownProps,
} from "streamdown";
import {
CodeBlock,
CodeBlockActions,
CodeBlockCopyButton,
CodeBlockFilename,
CodeBlockHeader,
CodeBlockTitle,
} from "@/components/ai-elements/code-block";
import { cn } from "@/lib/utils";
type MarkdownCodeProps = ComponentProps<"code"> & {
"data-block"?: boolean | string;
node?: {
properties?: {
metastring?: string;
};
};
};
const LANGUAGE_CLASS_PATTERN = /(?:^|\s)language-([^\s]+)/;
const START_LINE_PATTERN = /startLine=(\d+)/;
const NO_LINE_NUMBERS_PATTERN = /\bnoLineNumbers\b/;
function codeText(children: ReactNode): string {
if (typeof children === "string" || typeof children === "number") {
return String(children);
}
if (Array.isArray(children)) {
return children.map(codeText).join("");
}
if (isValidElement<{ children?: ReactNode }>(children)) {
return codeText(children.props.children);
}
return "";
}
const MarkdownCode = ({
children,
className,
node,
"data-block": dataBlock,
...props
}: MarkdownCodeProps) => {
const language = className?.match(LANGUAGE_CLASS_PATTERN)?.[1] ?? "text";
if (!dataBlock) {
return (
<code
className={cn(
"rounded bg-muted px-1.5 py-0.5 font-mono text-sm",
className,
)}
{...props}
>
{children}
</code>
);
}
const meta = node?.properties?.metastring;
const startLineMatch = meta?.match(START_LINE_PATTERN);
const startLine = startLineMatch ? Number.parseInt(startLineMatch[1], 10) : 1;
const showLineNumbers = meta ? !NO_LINE_NUMBERS_PATTERN.test(meta) : true;
return (
<CodeBlock
code={codeText(children)}
data-start-line={startLine > 1 ? startLine : undefined}
language={language}
showLineNumbers={showLineNumbers}
>
<CodeBlockHeader>
<CodeBlockTitle>
<CodeBlockFilename>{language}</CodeBlockFilename>
</CodeBlockTitle>
<CodeBlockActions>
<CodeBlockCopyButton />
</CodeBlockActions>
</CodeBlockHeader>
</CodeBlock>
);
};
const markdownComponents = {
code: MarkdownCode,
} satisfies Components;
const DEFAULT_MERMAID_CONFIG = {
fontFamily: "monospace",
securityLevel: "strict",
startOnLoad: false,
suppressErrorRendering: true,
theme: "default",
} satisfies MermaidConfig;
interface LazyMermaidInstance {
initialize: (config: MermaidConfig) => void;
render: (
id: string,
source: string,
) => Promise<{
svg: string;
}>;
}
function createLazyMermaidPlugin(): DiagramPlugin {
let config: MermaidConfig = DEFAULT_MERMAID_CONFIG;
let initialized = false;
const instance: LazyMermaidInstance = {
initialize(nextConfig: MermaidConfig) {
config = { ...DEFAULT_MERMAID_CONFIG, ...config, ...nextConfig };
initialized = false;
},
async render(id: string, source: string) {
const mermaidModule = await import("mermaid");
const mermaid = mermaidModule.default;
if (!initialized) {
mermaid.initialize(config);
initialized = true;
}
return mermaid.render(id, source);
},
};
return {
getMermaid(nextConfig?: MermaidConfig) {
if (nextConfig) {
instance.initialize(nextConfig);
}
return instance;
},
language: "mermaid",
name: "mermaid",
type: "diagram",
};
}
const streamdownPlugins = { cjk, mermaid: createLazyMermaidPlugin() };
export type HubStreamdownProps = StreamdownProps;
export const HubStreamdown = memo(
({ className, components, ...props }: HubStreamdownProps) => {
const mergedComponents = components
? { ...markdownComponents, ...components }
: markdownComponents;
return (
<Streamdown
className={className}
components={mergedComponents}
plugins={streamdownPlugins}
{...props}
/>
);
},
);
HubStreamdown.displayName = "HubStreamdown";
@@ -3,7 +3,6 @@ import {
Puzzle,
Search,
Server,
Star,
Trash2,
Zap,
} from "lucide-react";
@@ -27,7 +26,6 @@ import {
type MarketplacePrimitiveType,
type MarketplaceTag,
} from "@/lib/marketplace";
import { cn } from "@/lib/utils";
import { CommandBadge, PageFrame, PageHeader } from "./page-layout";
type EntryActionState =
@@ -116,13 +114,6 @@ function entryKey(entry: Pick<MarketplaceEntry, "id" | "type">): string {
return `${entry.type}:${entry.id}`;
}
function compareFeaturedEntries(
left: MarketplaceEntry,
right: MarketplaceEntry,
): number {
return Number(Boolean(right.featured)) - Number(Boolean(left.featured));
}
function normalizeMatchValue(value: string): string {
return value.trim().toLowerCase();
}
@@ -266,8 +257,6 @@ function MarketplaceEntryCard({
onToggleExpanded,
onUninstall,
matchedLocalItems = [],
showFeatured = true,
showTags = true,
sourceLabel,
tagLabels,
}: {
@@ -280,8 +269,6 @@ function MarketplaceEntryCard({
onToggleExpanded: (entry: MarketplaceEntry) => void;
onUninstall: (entry: MarketplaceEntry) => void;
matchedLocalItems?: MarketplaceLocalInstalledItem[];
showFeatured?: boolean;
showTags?: boolean;
sourceLabel?: string;
tagLabels: Map<string, string>;
}) {
@@ -312,63 +299,15 @@ function MarketplaceEntryCard({
: installed
? "Uninstall"
: "Install";
const statusMessage = inlineMessage ? (
<output
className={cn(
"text-xs",
actionState?.status === "failed"
? "text-destructive"
: "text-muted-foreground",
)}
>
{inlineMessage}
</output>
) : setupNeeded ? (
<span className="text-xs text-amber-700 dark:text-amber-300">
Requires setup after install
</span>
) : null;
const actionButton = (
<Button
disabled={!installedStatusReady || busy}
onClick={handleActionClick}
size="sm"
type="button"
variant={installed ? "destructive" : "default"}
>
{busy || !installedStatusReady ? <Spinner /> : null}
{installed && !busy ? <Trash2 className="size-4" /> : null}
{actionLabel}
</Button>
);
const content = (
<>
{!installed ? (
<div
className="absolute top-4 right-4"
data-marketplace-entry-interactive
>
{actionButton}
</div>
) : null}
<div className="min-w-0">
<div className="flex min-w-0 items-start justify-between gap-2">
<div
className={cn(
"flex min-w-0 flex-1 items-center gap-2",
!installed && "pr-24",
)}
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<EntryIcon className="h-4 w-4 shrink-0 text-primary" />
<h2 className="min-w-0 truncate text-sm font-semibold text-foreground">
<h2 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">
{entry.name}
</h2>
{showFeatured && entry.featured ? (
<Badge className="border border-violet-500/20 bg-violet-500/10 text-violet-700 dark:text-violet-300">
<Star className="fill-current" />
Featured
</Badge>
) : null}
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{sourceLabel ? (
@@ -410,34 +349,23 @@ function MarketplaceEntryCard({
) : null}
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-stretch sm:justify-between">
<div className="grid min-w-0 flex-1 gap-2">
{showTags && entry.tags.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{entry.tags.slice(0, 5).map((tag) => (
<Badge
key={tag}
variant="outline"
className="max-w-full text-muted-foreground"
>
<span className="truncate">{tagLabels.get(tag) ?? tag}</span>
</Badge>
))}
</div>
) : null}
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
{entry.description}
</p>
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
{entry.description}
</p>
{statusMessage}
{entry.tags.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{entry.tags.slice(0, 5).map((tag) => (
<Badge
key={tag}
variant="outline"
className="max-w-full text-muted-foreground"
>
<span className="truncate">{tagLabels.get(tag) ?? tag}</span>
</Badge>
))}
</div>
{installed ? (
<div className="flex shrink-0 flex-col items-start gap-1 sm:items-end sm:justify-end">
{actionButton}
</div>
) : null}
</div>
) : null}
{matchedLocalItems.some((item) => item.renderMatchedDetails) ? (
<div className="grid gap-2" data-marketplace-entry-details>
@@ -449,6 +377,36 @@ function MarketplaceEntryCard({
</div>
) : null}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-h-5 text-xs text-muted-foreground">
{inlineMessage ? (
<output
className={
actionState?.status === "failed"
? "text-destructive"
: "text-muted-foreground"
}
>
{inlineMessage}
</output>
) : setupNeeded ? (
<span className="text-amber-700 dark:text-amber-300">
Requires setup after install
</span>
) : null}
</div>
<Button
disabled={!installedStatusReady || busy}
onClick={handleActionClick}
type="button"
variant={installed ? "destructive" : "default"}
>
{busy || !installedStatusReady ? <Spinner /> : null}
{installed && !busy ? <Trash2 className="size-4" /> : null}
{actionLabel}
</Button>
</div>
{expanded && hasExpandableDetails ? (
<EntryDetails actionState={actionState} entry={entry} />
) : null}
@@ -457,7 +415,7 @@ function MarketplaceEntryCard({
if (!hasExpandableDetails) {
return (
<div className="relative grid gap-2 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20">
<div className="grid gap-3 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20">
{content}
</div>
);
@@ -468,7 +426,7 @@ function MarketplaceEntryCard({
<div
aria-expanded={expanded}
aria-label={`${expanded ? "Collapse" : "Expand"} ${entry.name}`}
className="relative grid cursor-pointer gap-2 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
className="grid cursor-pointer gap-3 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
onClick={(event) => {
if (
event.target instanceof HTMLElement &&
@@ -529,7 +487,6 @@ function MarketplaceSection({
emptyMessage,
entries,
expandedEntryKey,
headerContent,
installedEntryKeys,
installedStatusReady,
localOnlyInstalledItems = [],
@@ -537,8 +494,6 @@ function MarketplaceSection({
onInstall,
onToggleExpanded,
onUninstall,
showFeaturedBadges = true,
showEntryTags = true,
sourceLabel,
tagLabels,
title,
@@ -547,7 +502,6 @@ function MarketplaceSection({
emptyMessage: string;
entries: MarketplaceEntry[];
expandedEntryKey: string | null;
headerContent?: ReactNode;
installedEntryKeys: Set<string>;
installedStatusReady: boolean;
localOnlyInstalledItems?: MarketplaceLocalInstalledItem[];
@@ -555,8 +509,6 @@ function MarketplaceSection({
onInstall: (entry: MarketplaceEntry) => void;
onToggleExpanded: (entry: MarketplaceEntry) => void;
onUninstall: (entry: MarketplaceEntry) => void;
showFeaturedBadges?: boolean;
showEntryTags?: boolean;
sourceLabel?: string;
tagLabels: Map<string, string>;
title: string;
@@ -568,7 +520,6 @@ function MarketplaceSection({
<h2 className="text-base font-semibold text-foreground">{title}</h2>
<span className="text-sm text-muted-foreground">{totalCount}</span>
</div>
{headerContent}
{totalCount > 0 ? (
<div className="grid gap-3">
{localOnlyInstalledItems.map((item) => item.render())}
@@ -586,8 +537,6 @@ function MarketplaceSection({
onToggleExpanded={onToggleExpanded}
onUninstall={onUninstall}
matchedLocalItems={matchedLocalItemsByEntryKey?.get(key) ?? []}
showFeatured={showFeaturedBadges}
showTags={showEntryTags}
sourceLabel={sourceLabel}
tagLabels={tagLabels}
/>
@@ -677,48 +626,19 @@ export function MarketplaceView({
);
const primitiveEntries = useMemo(
() =>
(catalog?.entries.filter((entry) => entry.type === primitive) ?? []).sort(
compareFeaturedEntries,
),
() => catalog?.entries.filter((entry) => entry.type === primitive) ?? [],
[catalog?.entries, primitive],
);
const queryFilteredEntries = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
return primitiveEntries.filter((entry) => {
return (
normalizedQuery.length === 0 ||
entrySearchText(entry, tagLabels).includes(normalizedQuery)
);
});
}, [primitiveEntries, query, tagLabels]);
const installedEntries = useMemo(
() =>
queryFilteredEntries.filter((entry) =>
installedEntryKeys.has(entryKey(entry)),
),
[queryFilteredEntries, installedEntryKeys],
);
const marketplaceEntriesBeforeTag = useMemo(
() =>
queryFilteredEntries.filter(
(entry) => !installedEntryKeys.has(entryKey(entry)),
),
[queryFilteredEntries, installedEntryKeys],
);
const tagCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const entry of marketplaceEntriesBeforeTag) {
for (const entry of primitiveEntries) {
for (const tag of entry.tags) {
counts.set(tag, (counts.get(tag) ?? 0) + 1);
}
}
return counts;
}, [marketplaceEntriesBeforeTag]);
}, [primitiveEntries]);
const primitiveTags = useMemo(
() =>
@@ -726,20 +646,26 @@ export function MarketplaceView({
[catalog?.tags, tagCounts],
);
const catalogEntries = useMemo(
() =>
marketplaceEntriesBeforeTag.filter(
(entry) => !selectedTag || entry.tags.includes(selectedTag),
),
[marketplaceEntriesBeforeTag, selectedTag],
);
const filteredEntries = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
return primitiveEntries.filter((entry) => {
const matchesTag = !selectedTag || entry.tags.includes(selectedTag);
const matchesQuery =
normalizedQuery.length === 0 ||
entrySearchText(entry, tagLabels).includes(normalizedQuery);
return matchesTag && matchesQuery;
});
}, [primitiveEntries, query, selectedTag, tagLabels]);
const matchedLocalItemsByEntryKey = useMemo(() => {
const matched = new Map<string, MarketplaceLocalInstalledItem[]>();
for (const item of installedItems ?? []) {
for (const entry of installedEntries) {
for (const entry of filteredEntries) {
const key = entryKey(entry);
if (!entryMatchesLocalItem(entry, item)) {
if (
!installedEntryKeys.has(key) ||
!entryMatchesLocalItem(entry, item)
) {
continue;
}
const items = matched.get(key) ?? [];
@@ -748,78 +674,49 @@ export function MarketplaceView({
}
}
return matched;
}, [installedEntries, installedItems]);
}, [filteredEntries, installedEntryKeys, installedItems]);
const matchedLocalItemKeys = useMemo(() => {
const matched = new Set<string>();
const installedMarketplaceEntries = primitiveEntries.filter((entry) =>
installedEntryKeys.has(entryKey(entry)),
);
for (const item of installedItems ?? []) {
if (
installedMarketplaceEntries.some((entry) =>
entryMatchesLocalItem(entry, item),
)
) {
matched.add(item.key);
}
}
return matched;
}, [installedEntryKeys, installedItems, primitiveEntries]);
const matchedLocalItemKeys = useMemo(
() =>
new Set(
[...matchedLocalItemsByEntryKey.values()].flatMap((items) =>
items.map((item) => item.key),
),
),
[matchedLocalItemsByEntryKey],
);
const localOnlyInstalledItems = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
return (installedItems ?? []).filter((item) => {
if (matchedLocalItemKeys.has(item.key)) {
return false;
}
return (
normalizedQuery.length === 0 ||
item.matchValues
.map(normalizeMatchValue)
.some((value) => value.includes(normalizedQuery))
);
});
}, [installedItems, matchedLocalItemKeys, query]);
const localOnlyInstalledItems = useMemo(
() =>
(installedItems ?? []).filter(
(item) => !matchedLocalItemKeys.has(item.key),
),
[installedItems, matchedLocalItemKeys],
);
const installedEntries = useMemo(
() =>
filteredEntries.filter((entry) =>
installedEntryKeys.has(entryKey(entry)),
),
[filteredEntries, installedEntryKeys],
);
const catalogEntries = useMemo(
() =>
filteredEntries.filter(
(entry) => !installedEntryKeys.has(entryKey(entry)),
),
[filteredEntries, installedEntryKeys],
);
const activeFilters = query.trim().length > 0 || selectedTag !== null;
const installedStatusReady = installedStatusState === "ready";
const marketplaceTagFilters =
primitiveTags.length > 0 ? (
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div className="flex gap-2 overflow-x-auto pb-1">
{primitiveTags.map((tag) => (
<TagButton
active={selectedTag === tag.id}
count={tagCounts.get(tag.id) ?? 0}
key={tag.id}
onClick={() =>
setSelectedTag((current) =>
current === tag.id ? null : tag.id,
)
}
tag={tag}
/>
))}
</div>
<div className="flex min-h-8 shrink-0 items-center gap-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">
{catalogEntries.length}
</span>
<span>{catalogEntries.length === 1 ? "result" : "results"}</span>
{selectedTag ? (
<Button
onClick={() => setSelectedTag(null)}
size="sm"
type="button"
variant="ghost"
>
Clear filters
</Button>
) : null}
</div>
</div>
) : null;
const clearFilters = () => {
setQuery("");
setSelectedTag(null);
};
const setEntryState = (entry: MarketplaceEntry, state: EntryActionState) => {
const key = entryKey(entry);
@@ -966,7 +863,43 @@ export function MarketplaceView({
value={query}
/>
</div>
<div className="flex min-h-8 items-center gap-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">
{filteredEntries.length}
</span>
<span>
{filteredEntries.length === 1 ? "result" : "results"}
</span>
{activeFilters ? (
<Button
onClick={clearFilters}
size="sm"
type="button"
variant="ghost"
>
Clear filters
</Button>
) : null}
</div>
</div>
{primitiveTags.length > 0 ? (
<div className="flex gap-2 overflow-x-auto pb-1">
{primitiveTags.map((tag) => (
<TagButton
active={selectedTag === tag.id}
count={tagCounts.get(tag.id) ?? 0}
key={tag.id}
onClick={() =>
setSelectedTag((current) =>
current === tag.id ? null : tag.id,
)
}
tag={tag}
/>
))}
</div>
) : null}
</div>
<MarketplaceSection
@@ -981,8 +914,6 @@ export function MarketplaceView({
onInstall={installEntry}
onToggleExpanded={toggleExpanded}
onUninstall={uninstallEntry}
showFeaturedBadges={false}
showEntryTags={false}
sourceLabel="Marketplace"
tagLabels={tagLabels}
title="Installed"
@@ -993,7 +924,6 @@ export function MarketplaceView({
emptyMessage={pageDetails.emptyCatalog}
entries={catalogEntries}
expandedEntryKey={expandedEntryKey}
headerContent={marketplaceTagFilters}
installedEntryKeys={installedEntryKeys}
installedStatusReady={installedStatusReady}
onInstall={installEntry}
@@ -39,10 +39,10 @@ export type CustomizationSection =
const sectionDescriptions: Record<CustomizationSection, string> = {
Rules: "Review project and global rule files that shape Cline behavior.",
Hooks: "Inspect hook configuration and recent execution status.",
MCP: "Manage installed MCP servers and add new servers from the marketplace.",
Skills: "Manage installed skills and add new skills from the marketplace.",
MCP: "Manage installed MCP servers and add new servers from the catalog.",
Skills: "Manage installed skills and add new skills from the catalog.",
Agents: "Review configured agents discovered from local settings.",
Plugins: "Manage installed plugins and add new plugins from the marketplace.",
Plugins: "Manage installed plugins and add new plugins from the catalog.",
Tools: "Inspect built-in tools and tools contributed by plugins.",
};
@@ -17,7 +17,6 @@ export type MarketplaceEntry = {
id: string;
type: MarketplacePrimitiveType;
name: string;
featured?: boolean;
tagline: string;
description: string;
tags: string[];
@@ -101,7 +100,7 @@ export async function fetchMarketplaceCatalog(): Promise<MarketplaceCatalog> {
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(`Failed to fetch marketplace: ${response.status}`);
throw new Error(`Failed to fetch marketplace catalog: ${response.status}`);
}
const data = await response.json();
const baseUrl = typeof data?.baseUrl === "string" ? data.baseUrl : undefined;
@@ -153,10 +152,6 @@ export async function fetchMarketplaceCatalog(): Promise<MarketplaceCatalog> {
id: candidate.id,
type: candidate.type,
name: candidate.name,
featured:
typeof candidate.featured === "boolean"
? candidate.featured
: undefined,
tagline: candidate.tagline,
description: candidate.description,
tags: toStringArray(candidate.tags),
-29
View File
@@ -3,27 +3,6 @@ import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react-swc";
import { defineConfig } from "vite";
const mermaidChunkGroups = [
{
name: "mermaid-parser",
maxSize: 450_000,
test: /node_modules[\\/](?:\.bun[\\/])?@mermaid-js[+]parser/,
},
{
name: "mermaid-langium",
test: /node_modules[\\/](?:\.bun[\\/])?langium/,
},
{
name: "mermaid-layout",
maxSize: 450_000,
test: /node_modules[\\/](?:\.bun[\\/])?(?:cytoscape|cytoscape-cose-bilkent|dagre|elkjs)/,
},
{
name: "mermaid-markup",
test: /node_modules[\\/](?:\.bun[\\/])?(?:katex|dompurify)/,
},
];
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
@@ -46,13 +25,5 @@ export default defineConfig({
outDir: "../../dist/webview",
emptyOutDir: true,
cssMinify: "esbuild",
chunkSizeWarningLimit: 600,
rolldownOptions: {
output: {
codeSplitting: {
groups: mermaidChunkGroups,
},
},
},
},
});
+4
View File
@@ -1,3 +1,7 @@
<div align="center"><sub>
English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md" target="_blank">Español</a> | <a href="https://github.com/cline/cline/blob/main/locales/de/README.md" target="_blank">Deutsch</a> | <a href="https://github.com/cline/cline/blob/main/locales/ja/README.md" target="_blank">日本語</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-cn/README.md" target="_blank">简体中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-tw/README.md" target="_blank">繁體中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/ko/README.md" target="_blank">한국어</a>
</sub></div>
# Cline
<div align="center">
<table>
+3 -3
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.0",
"version": "3.89.0",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
@@ -346,8 +346,8 @@
"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": "bun run protos && bun run watch",
"watch": "bun run --parallel watch:esbuild watch:tsc",
"dev": "bun run watch",
"watch": "bun run protos && bun run --parallel watch:esbuild watch:tsc",
"watch:esbuild": "bun esbuild.mjs --watch",
"watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
"package": "bun run check-types && bun run build:webview && bun run lint && bun esbuild.mjs --production",
+31 -6
View File
@@ -135,6 +135,8 @@ message ProviderListing {
optional string default_model_id = 3;
optional string family = 4;
optional string protocol = 5;
// SDK/core-owned auth classification. Values: "api-key", "oauth", "local".
optional string auth_method = 17;
optional string auth_description = 6;
optional string base_url_description = 7;
bool allows_custom_model_ids = 8;
@@ -144,6 +146,26 @@ message ProviderListing {
// `@cline/llms`. When "hide", consumers must suppress per-token pricing
// and total cost displays (matches the CLI's `shouldShowCliUsageCost`).
string usage_cost_display = 11;
repeated ProviderConfigField config_fields = 15;
map<string, string> config_values_json = 16;
}
message ProviderConfigFieldOption {
string label = 1;
string value = 2;
optional string value_json = 3;
}
message ProviderConfigField {
string path = 1;
string label = 2;
string type = 3;
optional string placeholder = 4;
optional string description = 5;
bool secret = 6;
bool required = 7;
repeated ProviderConfigFieldOption options = 8;
optional string default_value_json = 9;
}
message ProviderListingsResponse {
@@ -249,6 +271,11 @@ message WriteProviderConfigPatch {
optional bool clear_headers = 10;
optional AwsProviderConfig aws = 11;
optional GcpProviderConfig gcp = 12;
// Generic SDK/core provider settings patch, encoded as JSON object and
// merged by the provider settings service. This lets the webview render and
// persist SDK-provided config field paths without hardcoding provider shapes
// into the proto.
optional string settings_json = 13;
}
message WriteProviderConfigRequest {
@@ -398,6 +425,7 @@ message ModelsApiOptions {
// Plan mode configurations
optional ApiProvider plan_mode_api_provider = 100;
optional string plan_mode_api_provider_id = 190;
optional string plan_mode_api_model_id = 101;
optional int64 plan_mode_thinking_budget_tokens = 102;
optional string plan_mode_reasoning_effort = 103;
@@ -439,6 +467,7 @@ message ModelsApiOptions {
// Act mode configurations
optional ApiProvider act_mode_api_provider = 200;
optional string act_mode_api_provider_id = 290;
optional string act_mode_api_model_id = 201;
optional int64 act_mode_thinking_budget_tokens = 202;
optional string act_mode_reasoning_effort = 203;
@@ -613,12 +642,6 @@ 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 {
@@ -761,6 +784,7 @@ message ModelsApiConfiguration {
// Plan mode configurations
optional ApiProvider plan_mode_api_provider = 100;
optional string plan_mode_api_provider_id = 190;
optional string plan_mode_api_model_id = 101;
optional int64 plan_mode_thinking_budget_tokens = 102;
optional string plan_mode_reasoning_effort = 103;
@@ -807,6 +831,7 @@ message ModelsApiConfiguration {
// Act mode configurations
optional ApiProvider act_mode_api_provider = 200;
optional string act_mode_api_provider_id = 290;
optional string act_mode_api_model_id = 201;
optional int64 act_mode_thinking_budget_tokens = 202;
optional string act_mode_reasoning_effort = 203;
+4
View File
@@ -285,6 +285,10 @@ 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 string plan_mode_cline_pass_model_id = 183;
optional OpenRouterModelInfo plan_mode_cline_pass_model_info = 184;
optional string act_mode_cline_pass_model_id = 185;
optional OpenRouterModelInfo act_mode_cline_pass_model_info = 186;
}
message State {
@@ -25,7 +25,7 @@ describe("clearOrganizationForClinePassProviderSelection", () => {
} as unknown as Controller
}
it("does nothing when ClinePass is not selected", async () => {
it("does nothing when Cline Pass is not selected", async () => {
await clearOrganizationForClinePassProviderSelection(createController(), {
planModeApiProvider: "cline",
actModeApiProvider: "openrouter",
@@ -34,7 +34,7 @@ describe("clearOrganizationForClinePassProviderSelection", () => {
assert.strictEqual(switchAccount.callCount, 0)
})
it("switches to the personal account when ClinePass is selected", async () => {
it("switches to the personal account when Cline Pass is selected", async () => {
await clearOrganizationForClinePassProviderSelection(createController(), {
planModeApiProvider: "cline-pass",
actModeApiProvider: "openrouter",
@@ -1,14 +1,20 @@
import type { ApiConfiguration } from "@shared/api"
import { ApiFormat, OpenRouterModelInfo } from "@shared/proto/cline/models"
import { afterEach, describe, expect, it, vi } from "vitest"
import type { EffectiveProviderConfig, ProviderCatalog, ProviderConfigStore } from "@/sdk/model-catalog/contracts"
import type {
EffectiveProviderConfig,
ProviderCatalog,
ProviderConfigStore,
ProviderListing,
} from "@/sdk/model-catalog/contracts"
import { computeConfigFingerprint } from "@/sdk/model-catalog/fingerprint"
import { parseProviderId } from "@/sdk/model-catalog/provider-id"
import { ApiFormat, OpenRouterModelInfo } from "@/shared/proto/cline/models"
import type { ProviderCatalogController } from "../providerCatalogShared"
type TestStateManager = {
setGlobalStateBatch: ReturnType<typeof vi.fn>
setGlobalStateBatch?: ReturnType<typeof vi.fn>
getApiConfiguration?: ReturnType<typeof vi.fn<() => ApiConfiguration | undefined>>
getRemoteConfigSettings?: ReturnType<typeof vi.fn<() => Record<string, unknown>>>
}
function makeStore(config: EffectiveProviderConfig): ProviderConfigStore {
@@ -21,16 +27,30 @@ function makeStore(config: EffectiveProviderConfig): ProviderConfigStore {
}
}
function makeCatalog(): ProviderCatalog {
function makeCatalog(providers: ProviderListing[] = []): ProviderCatalog {
return {
listProviders: vi.fn(async () => []),
invalidateProviderListings: vi.fn(),
listProviders: vi.fn(async () => providers),
resolveModels: vi.fn(),
peekModels: vi.fn(),
subscribe: vi.fn(() => ({ dispose: vi.fn() })),
}
}
function makeProviderListing(id: string, paths: string[]): ProviderListing {
return {
id: parseProviderId(id),
name: id,
defaultModelId: "test-model",
configFields: paths.map((path) => ({
path,
label: path,
type: path === "apiKey" || path.endsWith("secretKey") ? "password" : "text",
})),
allowsCustomModelIds: false,
usageCostDisplay: "show",
}
}
function makeController(
store: ProviderConfigStore,
catalog: ProviderCatalog,
@@ -62,6 +82,40 @@ describe("provider model catalog handlers", () => {
defaultModelId: "deepseek-v4-flash",
protocol: "openai-chat",
authDescription: "DeepSeek models",
configFields: [
{
path: "apiKey",
label: "API Key",
type: "password",
secret: true,
},
{
path: "aws.authentication",
label: "Authentication",
type: "select",
options: [
{ label: "AWS SDK / IAM", value: "iam" },
{ label: "API Key", value: "api-key" },
],
defaultValue: "iam",
},
{
path: "aws.useGlobalInference",
label: "Global Inference",
type: "boolean",
defaultValue: false,
},
],
configValues: {
apiKey: "SECRET_SENTINEL_LISTING_API_KEY",
baseUrl: "https://api.deepseek.com/v1",
headers: {
authorization: "Bearer SECRET_SENTINEL_HEADER",
"x-safe-header": "visible",
},
"aws.authentication": "api-key",
"aws.useGlobalInference": true,
},
allowsCustomModelIds: false,
usageCostDisplay: "show",
},
@@ -79,13 +133,81 @@ describe("provider model catalog handlers", () => {
protocol: "openai-chat",
authDescription: "DeepSeek models",
baseUrlDescription: undefined,
configFields: [
{
path: "apiKey",
label: "API Key",
type: "password",
placeholder: undefined,
description: undefined,
secret: true,
required: false,
options: [],
defaultValueJson: undefined,
},
{
path: "aws.authentication",
label: "Authentication",
type: "select",
placeholder: undefined,
description: undefined,
secret: false,
required: false,
options: [
{ label: "AWS SDK / IAM", value: "iam", valueJson: '"iam"' },
{ label: "API Key", value: "api-key", valueJson: '"api-key"' },
],
defaultValueJson: '"iam"',
},
{
path: "aws.useGlobalInference",
label: "Global Inference",
type: "boolean",
placeholder: undefined,
description: undefined,
secret: false,
required: false,
options: [],
defaultValueJson: "false",
},
],
configValuesJson: {
baseUrl: '"https://api.deepseek.com/v1"',
headers: '{"authorization":"","x-safe-header":"visible"}',
"aws.authentication": '"api-key"',
"aws.useGlobalInference": "true",
},
allowsCustomModelIds: false,
usageCostDisplay: "show",
},
])
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL")
expect(catalog.listProviders).toHaveBeenCalledTimes(1)
})
it("readProviderConfig redacts sensitive custom header values", async () => {
const { readProviderConfig } = await import("../readProviderConfig")
const providerId = parseProviderId("openai-compatible")
const store = makeStore({
providerId,
headers: {
authorization: "Bearer SECRET_SENTINEL_AUTH_HEADER",
cookie: "SECRET_SENTINEL_COOKIE",
"x-safe-header": "visible",
},
})
const controller = makeController(store, makeCatalog())
const response = await readProviderConfig(controller, { value: "openai-compatible" })
expect(response.headers).toEqual({
authorization: "",
cookie: "",
"x-safe-header": "visible",
})
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL")
})
it("resolveProviderModels returns full protobuf model metadata and request id", async () => {
const { resolveProviderModels } = await import("../resolveProviderModels")
const providerId = parseProviderId("deepseek")
@@ -176,7 +298,12 @@ describe("provider model catalog handlers", () => {
baseUrl: "http://localhost:11434/v1",
}
const store = makeStore(updatedConfig)
const controller = makeController(store, makeCatalog())
const controller = makeController(
store,
makeCatalog([
makeProviderListing("bedrock", ["apiKey", "aws.authentication", "aws.region", "aws.customModelBaseId"]),
]),
)
const response = await writeProviderConfig(controller, {
providerId: "ollama",
@@ -191,6 +318,126 @@ describe("provider model catalog handlers", () => {
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL")
})
it("writeProviderConfig routes settings_json through the provider config store", async () => {
const { writeProviderConfig } = await import("../writeProviderConfig")
const providerId = parseProviderId("bedrock")
const updatedConfig: EffectiveProviderConfig = {
providerId,
apiKey: "SECRET_SENTINEL_BEDROCK",
aws: {
authentication: "api-key",
region: "us-east-2",
customModelBaseId: "base-profile",
},
}
const store = makeStore(updatedConfig)
const controller = makeController(
store,
makeCatalog([
makeProviderListing("bedrock", ["apiKey", "aws.authentication", "aws.region", "aws.customModelBaseId"]),
]),
)
const response = await writeProviderConfig(controller, {
providerId: "bedrock",
patch: {
headers: {},
settingsJson: JSON.stringify({
apiKey: "SECRET_SENTINEL_BEDROCK",
aws: {
authentication: "api-key",
region: "us-east-2",
customModelBaseId: "base-profile",
},
auth: {
accessToken: "SECRET_SENTINEL_UNADVERTISED_TOKEN",
},
extras: {
unadvertised: true,
},
}),
},
})
expect(store.write).toHaveBeenCalledWith(providerId, {
settings: {
apiKey: "SECRET_SENTINEL_BEDROCK",
aws: {
authentication: "api-key",
region: "us-east-2",
customModelBaseId: "base-profile",
},
},
apiKey: "SECRET_SENTINEL_BEDROCK",
aws: {
authentication: "api-key",
region: "us-east-2",
customModelBaseId: "base-profile",
},
})
expect(response.apiKeyLength).toBe("SECRET_SENTINEL_BEDROCK".length)
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL")
expect(JSON.stringify(vi.mocked(store.write).mock.calls)).not.toContain("UNADVERTISED")
})
it("writeProviderConfig ignores settings_json writes for remotely locked provider fields", async () => {
const { writeProviderConfig } = await import("../writeProviderConfig")
const providerId = parseProviderId("openai-compatible")
const updatedConfig: EffectiveProviderConfig = {
providerId,
apiKey: "SECRET_SENTINEL_OPENAI",
baseUrl: "https://remote.example/v1",
}
const store = makeStore(updatedConfig)
const controller = makeController(
store,
makeCatalog([makeProviderListing("openai-compatible", ["apiKey", "baseUrl", "headers", "azure.apiVersion"])]),
{
getRemoteConfigSettings: vi.fn(() => ({
remoteConfiguredProviders: ["openai-compatible"],
openAiBaseUrl: "https://remote.example/v1",
openAiHeaders: { "x-remote": "locked" },
azureApiVersion: "2026-01-01-preview",
})),
},
)
await writeProviderConfig(controller, {
providerId: "openai-compatible",
patch: {
headers: {},
settingsJson: JSON.stringify({
apiKey: "SECRET_SENTINEL_OPENAI",
baseUrl: "https://local.example/v1",
headers: { "x-local": "blocked" },
azure: { apiVersion: "2024-02-15-preview" },
}),
},
})
expect(store.write).toHaveBeenCalledWith(providerId, {
settings: {
apiKey: "SECRET_SENTINEL_OPENAI",
},
apiKey: "SECRET_SENTINEL_OPENAI",
})
})
it("writeProviderConfig rejects non-object settings_json payloads", async () => {
const { writeProviderConfig } = await import("../writeProviderConfig")
const providerId = parseProviderId("deepseek")
const store = makeStore({ providerId })
const controller = makeController(store, makeCatalog())
await expect(
writeProviderConfig(controller, {
providerId: "deepseek",
patch: { headers: {}, settingsJson: JSON.stringify(["not", "an", "object"]) },
}),
).rejects.toThrow("settings_json must be a JSON object")
expect(store.write).not.toHaveBeenCalled()
})
it("writeProviderConfig can explicitly clear headers", async () => {
const { writeProviderConfig } = await import("../writeProviderConfig")
const providerId = parseProviderId("openai")
@@ -199,7 +446,7 @@ describe("provider model catalog handlers", () => {
headers: {},
}
const store = makeStore(updatedConfig)
const controller = makeController(store, makeCatalog())
const controller = makeController(store, makeCatalog([makeProviderListing("openai", ["headers"])]))
await writeProviderConfig(controller, {
providerId: "openai",
@@ -209,6 +456,26 @@ describe("provider model catalog handlers", () => {
expect(store.write).toHaveBeenCalledWith(providerId, { headers: {} })
})
it("writeProviderConfig ignores direct webview auth and unadvertised header writes", async () => {
const { writeProviderConfig } = await import("../writeProviderConfig")
const providerId = parseProviderId("deepseek")
const store = makeStore({ providerId })
const controller = makeController(store, makeCatalog([makeProviderListing("deepseek", ["apiKey"])]))
await writeProviderConfig(controller, {
providerId: "deepseek",
patch: {
apiKey: "SECRET_SENTINEL_API_KEY",
headers: { authorization: "Bearer SECRET_SENTINEL_HEADER" },
accessToken: "SECRET_SENTINEL_ACCESS",
},
})
expect(store.write).toHaveBeenCalledWith(providerId, {
apiKey: "SECRET_SENTINEL_API_KEY",
})
})
it("commitModelSelection validates mode and commits the full selection envelope", async () => {
const { commitModelSelection } = await import("../commitModelSelection")
const providerId = parseProviderId("deepseek")
@@ -239,11 +506,117 @@ describe("provider model catalog handlers", () => {
}),
})
expect(stateManager.setGlobalStateBatch).toHaveBeenCalledWith({
planModeApiProvider: "deepseek",
planModeApiModelId: "deepseek-v4-flash",
actModeApiProvider: "deepseek",
actModeApiModelId: "deepseek-v4-flash",
})
})
it("commitModelSelection persists SAP deployment metadata from SDK-owned model discovery", async () => {
const { commitModelSelection } = await import("../commitModelSelection")
const providerId = parseProviderId("sapaicore")
const store = makeStore({ providerId })
const catalog = makeCatalog()
const modelInfo = {
name: "Claude Sonnet deployment",
contextWindow: 128_000,
supportsPromptCache: true,
metadata: {
sap: {
deploymentId: "deployment-123",
},
},
}
vi.mocked(catalog.resolveModels).mockResolvedValue({
ok: true,
providerId,
configFingerprint: computeConfigFingerprint(providerId, { providerId }),
models: new Map([["anthropic--claude-3.5-sonnet", modelInfo]]),
defaultModelId: "anthropic--claude-3.5-sonnet",
source: "sdk-dynamic",
fetchedAt: 1,
})
const stateManager: TestStateManager = { setGlobalStateBatch: vi.fn() }
const controller = makeController(store, catalog, stateManager)
await commitModelSelection(controller, {
providerId: "sapaicore",
mode: "plan",
modelId: "anthropic--claude-3.5-sonnet",
modelInfo: OpenRouterModelInfo.create({
name: "Claude Sonnet deployment",
contextWindow: 128_000,
supportsPromptCache: true,
}),
})
expect(store.write).toHaveBeenCalledWith(providerId, {
mode: "plan",
sap: { deploymentId: "deployment-123" },
})
expect(store.commitSelection).toHaveBeenCalledWith(providerId, "plan", {
providerId,
modelId: "anthropic--claude-3.5-sonnet",
modelInfo,
})
})
it("commitModelSelection clamps stale selections to remote model allowlists before committing", async () => {
const { commitModelSelection } = await import("../commitModelSelection")
const providerId = parseProviderId("openai-compatible")
const store = makeStore({ providerId })
const catalog = makeCatalog()
const allowedModelInfo = {
name: "Allowed Model",
contextWindow: 128_000,
supportsPromptCache: true,
}
vi.mocked(catalog.resolveModels).mockResolvedValue({
ok: true,
providerId,
configFingerprint: computeConfigFingerprint(providerId, { providerId }),
models: new Map([["allowed-model", allowedModelInfo]]),
defaultModelId: "allowed-model",
source: "host-adapter",
fetchedAt: 1,
})
const stateManager: TestStateManager = {
setGlobalStateBatch: vi.fn(),
getRemoteConfigSettings: vi.fn(() => ({
remoteProviderModelSettings: {
"openai-compatible": {
models: [{ id: "allowed-model" }],
},
},
})),
}
const controller = makeController(store, catalog, stateManager)
await commitModelSelection(controller, {
providerId: "openai-compatible",
mode: "act",
modelId: "blocked-model",
modelInfo: OpenRouterModelInfo.create({
name: "Blocked Model",
contextWindow: 1_000,
apiFormat: ApiFormat.OPENAI_CHAT,
}),
})
expect(store.commitSelection).toHaveBeenCalledWith(providerId, "act", {
providerId,
modelId: "allowed-model",
modelInfo: allowedModelInfo,
})
expect(stateManager.setGlobalStateBatch).toHaveBeenCalledWith({
planModeApiProvider: "openai",
planModeOpenAiModelId: "allowed-model",
actModeApiProvider: "openai",
actModeOpenAiModelId: "allowed-model",
})
})
it("commitModelSelection reports provider changes when config is initialized", async () => {
const { commitModelSelection } = await import("../commitModelSelection")
const providerId = parseProviderId("deepseek")
@@ -96,6 +96,19 @@ describe("normalizeProviderSwitchModel", () => {
expect(store.readSelection).not.toHaveBeenCalled()
})
it("preserves unsupported VS Code providers in storage for rollback", () => {
const providerId = parseProviderId("cline")
const store = makeStore({ providerId })
const normalized = normalizeProviderSwitchModel(
store,
{ actModeApiProvider: "qwen-code" },
{ actModeApiProvider: "qwen-code" },
)
expect(normalized.actModeApiProvider).toBe("qwen-code")
})
it("normalizes plan mode independently", () => {
const providerId = parseProviderId("deepseek")
const defaultModelId = MODEL_COLLECTIONS_BY_PROVIDER_ID.deepseek.provider.defaultModelId
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest"
import { beforeEach, describe, expect, it, vi } from "vitest"
import type {
EffectiveProviderConfig,
ModelInfo,
@@ -11,6 +11,26 @@ import { computeConfigFingerprint } from "@/sdk/model-catalog/fingerprint"
import { parseProviderId } from "@/sdk/model-catalog/provider-id"
import type { ProviderCatalogController } from "../providerCatalogShared"
const mocks = vi.hoisted(() => {
let remoteConfigSettings: Record<string, unknown> = {}
return {
setRemoteConfigSettings(value: Record<string, unknown>): void {
remoteConfigSettings = value
},
getRemoteConfigSettings(): Record<string, unknown> {
return remoteConfigSettings
},
}
})
vi.mock("@/core/storage/StateManager", () => ({
StateManager: {
get: () => ({
getRemoteConfigSettings: mocks.getRemoteConfigSettings,
}),
},
}))
function fingerprint(providerId: ProviderId): ReturnType<typeof computeConfigFingerprint> {
return computeConfigFingerprint(providerId, { providerId })
}
@@ -28,7 +48,6 @@ function makeStore(config: EffectiveProviderConfig): ProviderConfigStore {
function makeCatalog(): ProviderCatalog {
return {
listProviders: vi.fn(async () => []),
invalidateProviderListings: vi.fn(),
resolveModels: vi.fn(async (providerId) => ({
ok: true as const,
providerId,
@@ -63,6 +82,10 @@ function peekResult(providerId: string, entries: Array<[string, ModelInfo]>, def
}
describe("resolveModelInfo", () => {
beforeEach(() => {
mocks.setRemoteConfigSettings({})
})
it("returns committed-selection source when a matching selection exists", async () => {
const { resolveModelInfo } = await import("../resolveModelInfo")
const providerId = parseProviderId("deepseek")
@@ -228,6 +251,42 @@ describe("resolveModelInfo", () => {
expect(response.modelInfo).toBeUndefined()
})
it("uses the remote allowlist default instead of stale committed custom model info", async () => {
const { resolveModelInfo } = await import("../resolveModelInfo")
const providerId = parseProviderId("openai")
const store = makeStore({ providerId })
vi.mocked(store.readSelection).mockReturnValue({
providerId,
modelId: "blocked-custom-model",
modelInfo: { name: "Blocked Custom", supportsPromptCache: false, contextWindow: 1_000 },
})
const catalog = makeCatalog()
vi.mocked(catalog.peekModels).mockReturnValue(
peekResult(
"openai",
[["allowed-model", { name: "Allowed Model", supportsPromptCache: false, contextWindow: 128_000 }]],
"allowed-model",
),
)
mocks.setRemoteConfigSettings({
remoteProviderModelSettings: {
"openai-compatible": {
models: [{ id: "allowed-model" }],
},
},
})
const response = await resolveModelInfo(makeController(store, catalog), {
providerId: "openai",
modelId: "blocked-custom-model",
})
expect(response.source).toBe("sdk-default")
expect(response.modelId).toBe("allowed-model")
expect(response.modelInfo?.contextWindow).toBe(128_000)
expect(store.readSelection).not.toHaveBeenCalled()
})
it("still honors a custom-provider model id that does match the catalog", async () => {
const { resolveModelInfo } = await import("../resolveModelInfo")
const store = makeStore({ providerId: parseProviderId("openai") })
@@ -0,0 +1,69 @@
import { describe, expect, it, vi } from "vitest"
import type { ApiConfiguration } from "@/shared/api"
import { ensureSharedModeApiConfiguration, mirrorPlanActApiConfiguration } from "../sharedModeConfiguration"
describe("shared mode API configuration", () => {
it("mirrors all plan/act fields from act mode", () => {
expect(
mirrorPlanActApiConfiguration({
planModeApiProvider: "openrouter",
actModeApiProvider: "anthropic",
planModeApiModelId: "plan-model",
actModeApiModelId: "act-model",
actModeReasoningEffort: "high",
} satisfies ApiConfiguration),
).toMatchObject({
planModeApiProvider: "anthropic",
actModeApiProvider: "anthropic",
planModeApiModelId: "act-model",
actModeApiModelId: "act-model",
planModeReasoningEffort: "high",
actModeReasoningEffort: "high",
})
})
it("falls back to plan mode only when act mode is empty", () => {
expect(
mirrorPlanActApiConfiguration({
planModeApiProvider: "openrouter",
planModeApiModelId: "plan-model",
} satisfies ApiConfiguration),
).toMatchObject({
planModeApiProvider: "openrouter",
actModeApiProvider: "openrouter",
planModeApiModelId: "plan-model",
actModeApiModelId: "plan-model",
})
})
it("persists mirrored config and disables the legacy separate-model flag", () => {
const setApiConfiguration = vi.fn()
const setGlobalState = vi.fn()
const controller = {
stateManager: {
getApiConfiguration: () =>
({
planModeApiProvider: "openrouter",
actModeApiProvider: "anthropic",
}) satisfies ApiConfiguration,
getGlobalSettingsKey: ((key: "planActSeparateModelsSetting") => true) as (
key: "planActSeparateModelsSetting",
) => boolean,
setApiConfiguration,
setGlobalState,
},
}
expect(ensureSharedModeApiConfiguration(controller)).toMatchObject({
planModeApiProvider: "anthropic",
actModeApiProvider: "anthropic",
})
expect(setApiConfiguration).toHaveBeenCalledWith(
expect.objectContaining({
planModeApiProvider: "anthropic",
actModeApiProvider: "anthropic",
}),
)
expect(setGlobalState).toHaveBeenCalledWith("planActSeparateModelsSetting", false)
})
})
@@ -0,0 +1,48 @@
import { ApiConfiguration, ModelsApiOptions, UpdateApiConfigurationRequestNew } from "@shared/proto/cline/models"
import { describe, expect, it, vi } from "vitest"
import type { ProviderConfigStore } from "@/sdk/model-catalog/contracts"
import { updateApiConfiguration } from "../updateApiConfiguration"
function makeStore(): ProviderConfigStore {
return {
read: vi.fn((providerId) => ({ providerId })),
readSelection: vi.fn(() => undefined),
subscribe: vi.fn(() => ({ dispose: vi.fn() })),
write: vi.fn((providerId) => ({ providerId })),
commitSelection: vi.fn(),
}
}
describe("updateApiConfiguration", () => {
it("stores masked SDK provider id fields under legacy provider keys", async () => {
const setGlobalStateBatch = vi.fn()
const controller = {
getProviderConfigStore: () => makeStore(),
stateManager: {
getApiConfiguration: vi.fn(() => ({ actModeApiProvider: "anthropic", planModeApiProvider: "anthropic" })),
getGlobalSettingsKey: vi.fn((key: string) => (key === "planActSeparateModelsSetting" ? false : "act")),
setGlobalStateBatch,
},
postStateToWebview: vi.fn(async () => undefined),
} as any
await updateApiConfiguration(
controller,
UpdateApiConfigurationRequestNew.create({
updates: ApiConfiguration.create({
options: ModelsApiOptions.create({
actModeApiProviderId: "poolside",
}),
}),
updateMask: ["options.actModeApiProviderId"],
}),
)
expect(setGlobalStateBatch).toHaveBeenCalledWith(
expect.objectContaining({
actModeApiProvider: "poolside",
planModeApiProvider: "poolside",
}),
)
})
})
@@ -0,0 +1,200 @@
import { describe, expect, it, vi } from "vitest"
import type { ProviderConfigStore } from "@/sdk/model-catalog/contracts"
import {
ApiProvider,
LiteLLMModelInfo,
ModelsApiConfiguration,
OcaModelInfo,
OpenAiCompatibleModelInfo,
UpdateApiConfigurationRequest,
} from "@/shared/proto/cline/models"
import { updateApiConfigurationProto } from "../updateApiConfigurationProto"
function makeStore(): ProviderConfigStore {
return {
read: vi.fn((providerId) => ({ providerId })),
readSelection: vi.fn(() => undefined),
subscribe: vi.fn(() => ({ dispose: vi.fn() })),
write: vi.fn((providerId) => ({ providerId })),
commitSelection: vi.fn(),
}
}
describe("updateApiConfigurationProto", () => {
it("preserves SDK-only provider ids from provider_id string fields", async () => {
const previousConfig = {
actModeApiProvider: "anthropic",
actModeApiModelId: "claude-sonnet-4-6",
}
const setApiConfiguration = vi.fn()
const controller = {
getProviderConfigStore: () => makeStore(),
stateManager: {
getApiConfiguration: vi.fn(() => previousConfig),
setApiConfiguration,
getGlobalSettingsKey: vi.fn(() => "act"),
},
handleApiConfigurationChanged: vi.fn(),
postStateToWebview: vi.fn(async () => undefined),
} as any
await updateApiConfigurationProto(
controller,
UpdateApiConfigurationRequest.create({
apiConfiguration: ModelsApiConfiguration.create({
actModeApiProviderId: "poolside",
}),
}),
)
expect(setApiConfiguration).toHaveBeenCalledWith(
expect.objectContaining({
actModeApiProvider: "poolside",
}),
)
expect(controller.handleApiConfigurationChanged).toHaveBeenCalledWith(
previousConfig,
expect.objectContaining({
actModeApiProvider: "poolside",
}),
)
})
it("preserves representative legacy provider fields through proto conversion", async () => {
const previousConfig = {
planModeApiProvider: "anthropic",
actModeApiProvider: "anthropic",
}
const setApiConfiguration = vi.fn()
const controller = {
getProviderConfigStore: () => makeStore(),
stateManager: {
getApiConfiguration: vi.fn(() => previousConfig),
setApiConfiguration,
getGlobalSettingsKey: vi.fn(() => "act"),
},
handleApiConfigurationChanged: vi.fn(),
postStateToWebview: vi.fn(async () => undefined),
} as any
await updateApiConfigurationProto(
controller,
UpdateApiConfigurationRequest.create({
apiConfiguration: ModelsApiConfiguration.create({
planModeApiProvider: ApiProvider.OPENAI,
planModeApiProviderId: "openai",
planModeOpenAiModelId: "azure-gpt-4.1",
planModeOpenAiModelInfo: OpenAiCompatibleModelInfo.create({
contextWindow: 128_000,
maxTokens: 16_384,
supportsImages: true,
supportsPromptCache: true,
isR1FormatRequired: true,
}),
actModeApiProvider: ApiProvider.LITELLM,
actModeApiProviderId: "litellm",
actModeLiteLlmModelId: "litellm-model",
actModeLiteLlmModelInfo: LiteLLMModelInfo.create({
contextWindow: 64_000,
maxTokens: 8_192,
supportsPromptCache: true,
}),
planModeOcaModelId: "oca-plan-model",
planModeOcaModelInfo: OcaModelInfo.create({
contextWindow: 200_000,
maxTokens: 32_000,
supportsImages: true,
supportsPromptCache: true,
}),
planModeSapAiCoreModelId: "sap-plan-model",
planModeSapAiCoreDeploymentId: "sap-plan-deployment",
actModeSapAiCoreModelId: "sap-act-model",
actModeSapAiCoreDeploymentId: "sap-act-deployment",
planModeAwsBedrockCustomSelected: true,
planModeAwsBedrockCustomModelBaseId: "plan-bedrock-base",
actModeAwsBedrockCustomSelected: true,
actModeAwsBedrockCustomModelBaseId: "act-bedrock-base",
planModeReasoningEffort: "high",
actModeReasoningEffort: "medium",
planModeOcaReasoningEffort: "low",
actModeOcaReasoningEffort: "high",
}),
}),
)
expect(setApiConfiguration).toHaveBeenCalledWith(
expect.objectContaining({
planModeApiProvider: "openai",
planModeOpenAiModelId: "azure-gpt-4.1",
planModeOpenAiModelInfo: expect.objectContaining({
contextWindow: 128_000,
maxTokens: 16_384,
supportsImages: true,
supportsPromptCache: true,
isR1FormatRequired: true,
}),
actModeApiProvider: "litellm",
actModeLiteLlmModelId: "litellm-model",
actModeLiteLlmModelInfo: expect.objectContaining({
contextWindow: 64_000,
maxTokens: 8_192,
supportsPromptCache: true,
}),
planModeOcaModelId: "oca-plan-model",
planModeOcaModelInfo: expect.objectContaining({
contextWindow: 200_000,
maxTokens: 32_000,
supportsImages: true,
supportsPromptCache: true,
}),
planModeSapAiCoreModelId: "sap-plan-model",
planModeSapAiCoreDeploymentId: "sap-plan-deployment",
actModeSapAiCoreModelId: "sap-act-model",
actModeSapAiCoreDeploymentId: "sap-act-deployment",
planModeAwsBedrockCustomSelected: true,
planModeAwsBedrockCustomModelBaseId: "plan-bedrock-base",
actModeAwsBedrockCustomSelected: true,
actModeAwsBedrockCustomModelBaseId: "act-bedrock-base",
planModeReasoningEffort: "high",
actModeReasoningEffort: "medium",
planModeOcaReasoningEffort: "low",
actModeOcaReasoningEffort: "high",
}),
)
})
it("preserves unsupported provider ids in storage so rollback can restore them", async () => {
const previousConfig = {
actModeApiProvider: "qwen-code",
actModeApiModelId: "qwen-code-model",
}
const setApiConfiguration = vi.fn()
const controller = {
getProviderConfigStore: () => makeStore(),
stateManager: {
getApiConfiguration: vi.fn(() => previousConfig),
setApiConfiguration,
getGlobalSettingsKey: vi.fn(() => "act"),
},
handleApiConfigurationChanged: vi.fn(),
postStateToWebview: vi.fn(async () => undefined),
} as any
await updateApiConfigurationProto(
controller,
UpdateApiConfigurationRequest.create({
apiConfiguration: ModelsApiConfiguration.create({
actModeApiProviderId: "qwen-code",
actModeApiModelId: "qwen-code-model",
}),
}),
)
expect(setApiConfiguration).toHaveBeenCalledWith(
expect.objectContaining({
actModeApiProvider: "qwen-code",
actModeApiModelId: "qwen-code-model",
}),
)
})
})
@@ -1,3 +1,5 @@
import type { ModelSelection, ProviderId } from "@/sdk/model-catalog/contracts"
import { openAiModelInfoSafeDefaults } from "@/shared/api"
import { toLegacyApiProvider } from "@/shared/model-catalog/provider-helpers"
import { Empty } from "@/shared/proto/cline/common"
import { CommitModelSelectionRequest } from "@/shared/proto/cline/models"
@@ -10,27 +12,123 @@ import {
toModelSelection,
} from "./providerCatalogShared"
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function readRemoteAllowedModelIds(controller: ProviderCatalogController, providerId: ProviderId): readonly string[] {
const remoteConfigSettings = (
controller as { stateManager?: { getRemoteConfigSettings?: () => unknown } }
).stateManager?.getRemoteConfigSettings?.()
if (!isRecord(remoteConfigSettings) || !isRecord(remoteConfigSettings.remoteProviderModelSettings)) {
return []
}
const settings = remoteConfigSettings.remoteProviderModelSettings[providerId.toString()]
if (!isRecord(settings)) {
return []
}
const models = Array.isArray(settings.models) ? settings.models : []
const bedrockCustomModels = Array.isArray(settings.bedrockCustomModels) ? settings.bedrockCustomModels : []
return [
...models.map((model) => (isRecord(model) && typeof model.id === "string" ? model.id : "")),
...bedrockCustomModels.map((model) => (isRecord(model) && typeof model.name === "string" ? model.name : "")),
].filter((modelId) => modelId.trim().length > 0)
}
async function coerceSelectionToRemoteAllowlist(
controller: ProviderCatalogController,
providerId: ProviderId,
selection: ModelSelection,
): Promise<ModelSelection> {
const allowedModelIds = readRemoteAllowedModelIds(controller, providerId)
if (allowedModelIds.length === 0 || allowedModelIds.includes(selection.modelId)) {
return selection
}
const modelId = allowedModelIds[0]
const cachedModels = controller.getProviderCatalog().peekModels(providerId)
const resolvedModels = cachedModels?.ok ? cachedModels : await controller.getProviderCatalog().resolveModels(providerId)
const modelInfo =
resolvedModels.ok && resolvedModels.models.has(modelId)
? resolvedModels.models.get(modelId)
: { ...openAiModelInfoSafeDefaults, name: modelId }
return {
providerId,
modelId,
modelInfo: modelInfo ?? { ...openAiModelInfoSafeDefaults, name: modelId },
}
}
async function enrichSelectionFromCatalog(
controller: ProviderCatalogController,
providerId: ProviderId,
selection: ModelSelection,
): Promise<{ selection: ModelSelection; modelWasLoaded: boolean }> {
const cachedModels = controller.getProviderCatalog().peekModels(providerId)
const resolvedModels = cachedModels?.ok
? cachedModels
: await Promise.resolve(controller.getProviderCatalog().resolveModels(providerId)).catch(() => undefined)
if (!resolvedModels?.ok) {
return { selection, modelWasLoaded: false }
}
const modelInfo = resolvedModels.models.get(selection.modelId)
if (!modelInfo) {
return { selection, modelWasLoaded: false }
}
return {
selection: {
...selection,
modelInfo,
},
modelWasLoaded: true,
}
}
function readSapDeploymentId(modelInfo: ModelSelection["modelInfo"]): string | undefined {
const metadata = (modelInfo as ModelSelection["modelInfo"] & { metadata?: Record<string, unknown> }).metadata
const sap = metadata?.sap
if (!sap || typeof sap !== "object" || Array.isArray(sap)) {
return undefined
}
const deploymentId = (sap as Record<string, unknown>).deploymentId
return typeof deploymentId === "string" && deploymentId.trim().length > 0 ? deploymentId.trim() : undefined
}
export async function commitModelSelection(
controller: ProviderCatalogController,
request: CommitModelSelectionRequest,
): Promise<Empty> {
const providerId = parseProviderIdRequest(request.providerId)
const mode = parseModeRequest(request.mode)
const selection = toModelSelection(request, providerId)
const coercedSelection = await coerceSelectionToRemoteAllowlist(controller, providerId, toModelSelection(request, providerId))
const { selection, modelWasLoaded } = await enrichSelectionFromCatalog(controller, providerId, coercedSelection)
const previousApiConfiguration = hasProviderCatalogStateController(controller)
? controller.stateManager.getApiConfiguration?.()
: undefined
controller.getProviderConfigStore().commitSelection(providerId, mode, selection)
const store = controller.getProviderConfigStore()
if (providerId.toString() === "sapaicore" && modelWasLoaded) {
store.write(providerId, { mode, sap: { deploymentId: readSapDeploymentId(selection.modelInfo) ?? "" } })
}
store.commitSelection(providerId, mode, selection)
if (hasProviderCatalogStateController(controller)) {
const legacyProviderId = toLegacyApiProvider(providerId.toString())
controller.stateManager.setGlobalStateBatch({
[`${mode}ModeApiProvider`]: providerId,
[getProviderModelIdKey(toLegacyApiProvider(providerId.toString()), mode)]: selection.modelId,
planModeApiProvider: legacyProviderId,
actModeApiProvider: legacyProviderId,
[getProviderModelIdKey(legacyProviderId, "plan")]: selection.modelId,
[getProviderModelIdKey(legacyProviderId, "act")]: selection.modelId,
})
const nextApiConfiguration = controller.stateManager.getApiConfiguration?.()
if (nextApiConfiguration) {
controller.handleApiConfigurationChanged?.(previousApiConfiguration ?? {}, nextApiConfiguration)
}
await controller.postStateToWebview?.()
}
return Empty.create()
@@ -0,0 +1,73 @@
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
import { fetch } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
/**
* Shape of the LiteLLM `/v1/model/info` response.
*/
export interface LiteLlmModelInfoResponse {
data: Array<{
model_name: string
litellm_params: {
model: string
[key: string]: any
}
model_info: {
input_cost_per_token: number
output_cost_per_token: number
cache_creation_input_token_cost?: number
cache_read_input_token_cost?: number
supports_prompt_caching?: boolean
[key: string]: any
}
}>
}
/**
* Fetch LiteLLM model info from a LiteLLM proxy.
*
* @param baseUrl The base URL for the LiteLLM API
* @param apiKey The API key for authentication
* @returns The model info response or undefined if fetch fails
*/
export async function fetchLiteLlmModelsInfo(baseUrl: string, apiKey: string): Promise<LiteLlmModelInfoResponse | undefined> {
// Handle base URLs that already include /v1 to avoid double /v1/v1/
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`
const url = `${normalizedBaseUrl}/model/info`
try {
const response = await fetch(url, {
method: "GET",
headers: {
accept: "application/json",
"x-litellm-api-key": apiKey,
...buildExternalBasicHeaders(),
},
})
if (response.ok) {
const data: LiteLlmModelInfoResponse = await response.json()
return data
}
Logger.error("Failed to fetch LiteLLM model info:", response.statusText)
// Try with Authorization header instead
const retryResponse = await fetch(url, {
method: "GET",
headers: {
accept: "application/json",
Authorization: `Bearer ${apiKey}`,
...buildExternalBasicHeaders(),
},
})
if (retryResponse.ok) {
const data: LiteLlmModelInfoResponse = await retryResponse.json()
return data
}
Logger.error("Failed to fetch LiteLLM model info with Authorization header:", retryResponse.statusText)
throw new Error(`Failed to fetch LiteLLM model info: ${retryResponse.statusText}`)
} catch (error) {
Logger.error("Error fetching LiteLLM model info:", error)
throw error
}
}
@@ -5,7 +5,7 @@ import type { Controller } from "../index"
export const CLINE_PASS_PROVIDER_ID = "cline-pass"
/**
* ClinePass always uses the user's personal Cline account balance.
* Cline Pass always uses the user's personal Cline account balance.
*
* This is intentionally best-effort: selecting the provider should still be
* saved even if the account switch fails.
@@ -24,6 +24,6 @@ export async function clearOrganizationForClinePassProviderSelection(
try {
await controller.accountService.switchAccount(undefined)
} catch (error) {
Logger.debug("Failed to switch ClinePass to personal account", { error })
Logger.debug("Failed to switch Cline Pass to personal account", { error })
}
}
@@ -18,6 +18,8 @@ import {
CommittedModelSelection,
GcpProviderConfig,
OpenRouterModelInfo,
ProviderConfigFieldOption,
ProviderConfigField as ProviderConfigFieldProto,
ProviderConfigResponse,
ProviderListing as ProviderListingProto,
ProviderModelsResponse,
@@ -37,6 +39,7 @@ export interface ProviderCatalogStateController extends ProviderCatalogControlle
getApiConfiguration?(): ApiConfiguration
}
handleApiConfigurationChanged?(previous: ApiConfiguration, next: ApiConfiguration): void
postStateToWebview?(): Promise<void>
}
export function hasProviderCatalogStateController(
@@ -61,15 +64,59 @@ export function parseModeRequest(rawMode: string | undefined): Mode {
throw new Error('mode must be "plan" or "act"')
}
const SENSITIVE_HEADER_NAME_PATTERN = /(?:^|[-_])(authorization|cookie|api[-_]?key|token|secret|credential|session)(?:$|[-_])/i
function isSensitiveHeaderName(name: string): boolean {
return SENSITIVE_HEADER_NAME_PATTERN.test(name)
}
function redactHeaders(headers: Readonly<Record<string, string>> | undefined): Record<string, string> {
if (!headers) {
return {}
}
return Object.fromEntries(Object.entries(headers).map(([name, value]) => [name, isSensitiveHeaderName(name) ? "" : value]))
}
export function toProviderListingProto(listing: ProviderListing): ProviderListingProto {
const secretFieldPaths = new Set(listing.configFields?.filter((field) => field.secret).map((field) => field.path) ?? [])
return ProviderListingProto.create({
id: listing.id,
name: listing.name,
defaultModelId: listing.defaultModelId,
family: listing.family,
protocol: listing.protocol,
authMethod: listing.authMethod,
authDescription: listing.authDescription,
baseUrlDescription: listing.baseUrlDescription,
configFields: listing.configFields?.map((field) =>
ProviderConfigFieldProto.create({
path: field.path,
label: field.label,
type: field.type,
placeholder: field.placeholder,
description: field.description,
secret: field.secret ?? false,
required: field.required ?? false,
options: field.options?.map((option) =>
ProviderConfigFieldOption.create({
label: option.label,
value: String(option.value),
valueJson: JSON.stringify(option.value),
}),
),
defaultValueJson: field.defaultValue !== undefined ? JSON.stringify(field.defaultValue) : undefined,
}),
),
configValuesJson: Object.fromEntries(
Object.entries(listing.configValues ?? {})
.filter(([path]) => !secretFieldPaths.has(path))
.map(([path, value]) => [
path,
JSON.stringify(
path === "headers" && typeof value === "object" ? redactHeaders(value as Record<string, string>) : value,
),
]),
),
allowsCustomModelIds: listing.allowsCustomModelIds,
usageCostDisplay: listing.usageCostDisplay,
})
@@ -188,7 +235,7 @@ export function toRedactedProviderConfigResponse(
providerId: config.providerId,
baseUrl: config.baseUrl,
apiLine: config.apiLine,
headers: config.headers ?? {},
headers: redactHeaders(config.headers),
region: config.region,
apiKeyLength: config.apiKey?.length ?? 0,
hasAccessToken: Boolean(config.auth?.accessToken),
@@ -3,6 +3,7 @@ import type { Mode, ProviderConfigStore, ProviderId } from "@/sdk/model-catalog/
import { parseProviderId } from "@/sdk/model-catalog/provider-id"
import { toSdkProviderId } from "@/sdk/model-catalog/sdk-provider-id"
import type { ApiConfiguration, ApiProvider } from "@/shared/api"
import { isVscodeUnsupportedProvider } from "@/shared/model-catalog/provider-helpers"
import { getProviderModelIdKey } from "@/shared/storage/provider-keys"
type ProviderSwitchConfig = Partial<
@@ -67,10 +68,18 @@ export function normalizeProviderSwitchModel<T extends ProviderSwitchConfig>(
for (const [mode, fields] of Object.entries(modeFields) as [Mode, (typeof modeFields)[Mode]][]) {
const previousProvider = previous[fields.provider]
const nextProvider = (normalized[fields.provider] ?? previousProvider) as ApiProvider | undefined
const requestedProvider = (normalized[fields.provider] ?? previousProvider) as ApiProvider | undefined
if (!requestedProvider) {
continue
}
const nextProvider = requestedProvider
if (!nextProvider || nextProvider === previousProvider) {
continue
}
if (isVscodeUnsupportedProvider(nextProvider)) {
continue
}
const providerId = toProviderId(nextProvider)
if (!providerId) {
@@ -1,10 +1,9 @@
import type { ModelInfo } from "@shared/api"
import { OpenRouterCompatibleModelInfo } from "@shared/proto/cline/models"
import { StateManager } from "@/core/storage/StateManager"
import { parseProviderId } from "@/sdk/model-catalog/provider-id"
import { toProtobufModels } from "@/shared/proto-conversions/models/typeConversion"
import { Logger } from "@/shared/services/Logger"
import type { ProviderCatalogController } from "./providerCatalogShared"
import { fetchLiteLlmModelsInfo } from "./fetchLiteLlmModels"
import { sendLiteLlmModelsEvent } from "./subscribeToLiteLlmModels"
/**
@@ -12,32 +11,74 @@ import { sendLiteLlmModelsEvent } from "./subscribeToLiteLlmModels"
* @param controller The controller instance
* @returns Record of model ID to ModelInfo (application types)
*/
export async function refreshLiteLlmModels(controller: ProviderCatalogController): Promise<Record<string, ModelInfo>> {
export async function refreshLiteLlmModels(): Promise<Record<string, ModelInfo>> {
const models: Record<string, ModelInfo> = {}
const stateManager = StateManager.get()
try {
const result = await controller.getProviderCatalog().resolveModels(parseProviderId("litellm"), { forceRefresh: true })
if (!result.ok) {
throw new Error(result.error.message)
// Get the LiteLLM configuration
const apiConfiguration = stateManager.getApiConfiguration()
const baseUrl = apiConfiguration.liteLlmBaseUrl || "http://localhost:4000"
const apiKey = apiConfiguration.liteLlmApiKey
if (!apiKey) {
throw new Error("LiteLLM API key is not configured or is invalid")
}
const models: Record<string, ModelInfo> = Object.fromEntries(result.models)
// Use the shared utility function to fetch model info
const data = await fetchLiteLlmModelsInfo(baseUrl, apiKey)
// Store in StateManager's in-memory cache
StateManager.get().setModelsCache("liteLlm", models)
if (data?.data) {
for (const rawModel of data.data) {
const modelInfo: ModelInfo = {
name: rawModel.model_name,
maxTokens: rawModel.model_info?.max_output_tokens ?? rawModel.model_info?.max_tokens ?? 4096,
contextWindow: rawModel.model_info?.max_input_tokens ?? rawModel.model_info?.max_tokens ?? 8192,
supportsImages: rawModel.model_info?.supports_vision ?? false,
supportsPromptCache: rawModel.model_info?.supports_prompt_caching ?? false,
supportsReasoning: rawModel.model_info?.supports_reasoning ?? false,
inputPrice: rawModel.model_info?.input_cost_per_token
? rawModel.model_info.input_cost_per_token * 1_000_000
: 0,
outputPrice: rawModel.model_info?.output_cost_per_token
? rawModel.model_info.output_cost_per_token * 1_000_000
: 0,
cacheWritesPrice: rawModel.model_info?.cache_creation_input_token_cost
? rawModel.model_info.cache_creation_input_token_cost * 1_000_000
: undefined,
cacheReadsPrice: rawModel.model_info?.cache_read_input_token_cost
? rawModel.model_info.cache_read_input_token_cost * 1_000_000
: undefined,
description: undefined,
}
// Send event to subscribers
try {
await sendLiteLlmModelsEvent(
OpenRouterCompatibleModelInfo.create({
models: toProtobufModels(models),
}),
)
} catch (error) {
Logger.error("Error sending LiteLLM models event:", error)
// Use litellm_params.model as the key since that's the actual model ID users select
// model_name may not include the region prefix (e.g., "us." for Bedrock models)
if (rawModel.litellm_params?.model) {
models[rawModel.litellm_params?.model] = modelInfo
}
models[rawModel.model_name] = modelInfo
}
}
return models
} catch (error) {
Logger.error("Error fetching LiteLLM models:", error)
throw error
}
// Store in StateManager's in-memory cache
StateManager.get().setModelsCache("liteLlm", models)
// Send event to subscribers
try {
await sendLiteLlmModelsEvent(
OpenRouterCompatibleModelInfo.create({
models: toProtobufModels(models),
}),
)
} catch (error) {
Logger.error("Error sending LiteLLM models event:", error)
}
return models
}
@@ -11,10 +11,10 @@ import { refreshLiteLlmModels } from "./refreshLiteLlmModels"
* @returns OpenRouterCompatibleModelInfo with protobuf types
*/
export async function refreshLiteLlmModelsRpc(
controller: Controller,
_controller: Controller,
_request: EmptyRequest,
): Promise<OpenRouterCompatibleModelInfo> {
const models = await refreshLiteLlmModels(controller)
const models = await refreshLiteLlmModels()
return OpenRouterCompatibleModelInfo.create({
models: toProtobufModels(models),
})
@@ -114,58 +114,38 @@ export async function refreshOcaModels(controller: Controller, request: StringRe
// Fetch current config to determine existing model selections
const apiConfiguration = controller.stateManager.getApiConfiguration()
const planActSeparateModelsSetting = controller.stateManager.getGlobalSettingsKey("planActSeparateModelsSetting")
const currentMode = controller.stateManager.getGlobalSettingsKey("mode")
const planModeSelectedModelId: string =
apiConfiguration?.planModeOcaModelId && models[apiConfiguration.planModeOcaModelId]
? apiConfiguration.planModeOcaModelId
: defaultModelId
const actModeSelectedModelId: string =
apiConfiguration?.actModeOcaModelId && models[apiConfiguration.actModeOcaModelId]
? apiConfiguration.actModeOcaModelId
: defaultModelId
const preferredModelId =
currentMode === "plan" ? apiConfiguration?.planModeOcaModelId : apiConfiguration?.actModeOcaModelId
const fallbackModelId =
currentMode === "plan" ? apiConfiguration?.actModeOcaModelId : apiConfiguration?.planModeOcaModelId
const selectedModelId: string =
(preferredModelId && models[preferredModelId] ? preferredModelId : undefined) ??
(fallbackModelId && models[fallbackModelId] ? fallbackModelId : undefined) ??
defaultModelId
let planModeOcaReasoningEffort: string | undefined
let actModeOcaReasoningEffort: string | undefined
if (
models[planModeSelectedModelId].supportsReasoning &&
models[planModeSelectedModelId].reasoningEffortOptions.length > 0
) {
planModeOcaReasoningEffort = apiConfiguration.planModeOcaReasoningEffort
? apiConfiguration.planModeOcaReasoningEffort
: models[planModeSelectedModelId].reasoningEffortOptions[0]
}
if (
models[actModeSelectedModelId].supportsReasoning &&
models[actModeSelectedModelId].reasoningEffortOptions.length > 0
) {
actModeOcaReasoningEffort = apiConfiguration.actModeOcaReasoningEffort
? apiConfiguration.actModeOcaReasoningEffort
: models[actModeSelectedModelId].reasoningEffortOptions[0]
let reasoningEffort: string | undefined
if (models[selectedModelId].supportsReasoning && models[selectedModelId].reasoningEffortOptions.length > 0) {
const preferredReasoningEffort =
currentMode === "plan"
? apiConfiguration.planModeOcaReasoningEffort
: apiConfiguration.actModeOcaReasoningEffort
const fallbackReasoningEffort =
currentMode === "plan"
? apiConfiguration.actModeOcaReasoningEffort
: apiConfiguration.planModeOcaReasoningEffort
reasoningEffort =
preferredReasoningEffort ?? fallbackReasoningEffort ?? models[selectedModelId].reasoningEffortOptions[0]
}
// Build updates object based on plan/act mode setting
const updates: Partial<GlobalStateAndSettings> = {}
if (planActSeparateModelsSetting) {
if (currentMode === "plan") {
updates.planModeOcaModelId = planModeSelectedModelId
updates.planModeOcaModelInfo = models[planModeSelectedModelId]
updates.planModeOcaReasoningEffort = planModeOcaReasoningEffort
} else {
updates.actModeOcaModelId = actModeSelectedModelId
updates.actModeOcaModelInfo = models[actModeSelectedModelId]
updates.actModeOcaReasoningEffort = actModeOcaReasoningEffort
}
} else {
updates.planModeOcaModelId = planModeSelectedModelId
updates.planModeOcaModelInfo = models[planModeSelectedModelId]
updates.planModeOcaReasoningEffort = planModeOcaReasoningEffort
updates.actModeOcaModelId = actModeSelectedModelId
updates.actModeOcaModelInfo = models[actModeSelectedModelId]
updates.actModeOcaReasoningEffort = actModeOcaReasoningEffort
}
updates.planModeOcaModelId = selectedModelId
updates.planModeOcaModelInfo = models[selectedModelId]
updates.planModeOcaReasoningEffort = reasoningEffort
updates.actModeOcaModelId = selectedModelId
updates.actModeOcaModelInfo = models[selectedModelId]
updates.actModeOcaReasoningEffort = reasoningEffort
// Update state directly using batch method
controller.stateManager.setGlobalStateBatch(updates)
@@ -1,5 +1,5 @@
import { providerAllowsCustomModelIds, providerHasRemoteModelAllowlist } from "@/sdk/model-catalog/catalog"
import type { ProviderModelsResult } from "@/sdk/model-catalog/contracts"
import { providerAllowsCustomModelIds } from "@/sdk/model-catalog/custom-model-ids"
import { ResolveModelInfoRequest, ResolveModelInfoResponse } from "@/shared/proto/cline/models"
import { toProtobufModelInfo } from "@/shared/proto-conversions/models/typeConversion"
import { type ProviderCatalogController, parseProviderIdRequest } from "./providerCatalogShared"
@@ -41,7 +41,8 @@ export async function resolveModelInfo(
const requestedModelId = request.modelId?.trim() || ""
const store = controller.getProviderConfigStore()
if (requestedModelId) {
const remoteModelAllowlistActive = providerHasRemoteModelAllowlist(providerId)
if (requestedModelId && !remoteModelAllowlistActive) {
const actSelection = store.readSelection(providerId, "act")
if (actSelection?.modelId === requestedModelId) {
return ResolveModelInfoResponse.create({
@@ -0,0 +1,65 @@
import type { ApiConfiguration } from "@/shared/api"
function isModeKey(key: string, prefix: "planMode" | "actMode"): boolean {
return key.startsWith(prefix) && key.length > prefix.length
}
function collectModeSuffixes(config: ApiConfiguration): Set<string> {
const suffixes = new Set<string>()
for (const key of Object.keys(config)) {
if (isModeKey(key, "planMode")) {
suffixes.add(key.slice("planMode".length))
} else if (isModeKey(key, "actMode")) {
suffixes.add(key.slice("actMode".length))
}
}
return suffixes
}
function chooseSharedValue(planValue: unknown, actValue: unknown): unknown {
return actValue !== undefined ? actValue : planValue
}
export function mirrorPlanActApiConfiguration(config: ApiConfiguration): ApiConfiguration {
const next: ApiConfiguration = { ...config }
for (const suffix of collectModeSuffixes(config)) {
const planKey = `planMode${suffix}` as keyof ApiConfiguration
const actKey = `actMode${suffix}` as keyof ApiConfiguration
const sharedValue = chooseSharedValue(next[planKey], next[actKey])
if (sharedValue === undefined) {
continue
}
next[planKey] = sharedValue as never
next[actKey] = sharedValue as never
}
return next
}
function apiConfigurationsEqual(left: ApiConfiguration, right: ApiConfiguration): boolean {
const keys = new Set([...Object.keys(left), ...Object.keys(right)])
for (const key of keys) {
if (left[key as keyof ApiConfiguration] !== right[key as keyof ApiConfiguration]) {
return false
}
}
return true
}
export function ensureSharedModeApiConfiguration(controller: {
stateManager: {
getApiConfiguration(): ApiConfiguration
getGlobalSettingsKey(key: "planActSeparateModelsSetting"): boolean
setApiConfiguration(config: ApiConfiguration): void
setGlobalState(key: "planActSeparateModelsSetting", value: boolean): void
}
}): ApiConfiguration {
const current = controller.stateManager.getApiConfiguration()
const next = mirrorPlanActApiConfiguration(current)
if (!apiConfigurationsEqual(current, next)) {
controller.stateManager.setApiConfiguration(next)
}
if (controller.stateManager.getGlobalSettingsKey("planActSeparateModelsSetting") !== false) {
controller.stateManager.setGlobalState("planActSeparateModelsSetting", false)
}
return next
}
@@ -50,6 +50,36 @@ function getAlternateModeField(fieldName: string): string | null {
return null
}
function assignMaskedOption(
options: Partial<ApiHandlerOptions> & { planModeApiProvider?: ApiProvider; actModeApiProvider?: ApiProvider },
key: string,
value: unknown,
): void {
if (key === "planModeApiProvider") {
options.planModeApiProvider = convertProtoToApiProvider(value as never)
return
}
if (key === "actModeApiProvider") {
options.actModeApiProvider = convertProtoToApiProvider(value as never)
return
}
if (key === "planModeApiProviderId") {
const providerId = typeof value === "string" ? value.trim() : ""
if (providerId) {
options.planModeApiProvider = providerId as ApiProvider
}
return
}
if (key === "actModeApiProviderId") {
const providerId = typeof value === "string" ? value.trim() : ""
if (providerId) {
options.actModeApiProvider = providerId as ApiProvider
}
return
}
options[key as keyof ApiHandlerOptions] = value as never
}
/**
* Updates API configuration using field mask
* @param controller The controller instance
@@ -101,33 +131,14 @@ export async function updateApiConfiguration(controller: Controller, request: Up
}
}
// Check if mode-specific configurations should be kept separate
const separateModeConfigs = controller.stateManager.getGlobalSettingsKey("planActSeparateModelsSetting")
// Process entries that are in the mask
for (const [key, value] of Object.entries(protoOptions)) {
if (maskOptionsFields.has(key)) {
// Handle enum conversions
if (key === "planModeApiProvider") {
options.planModeApiProvider = convertProtoToApiProvider(value)
} else if (key === "actModeApiProvider") {
options.actModeApiProvider = convertProtoToApiProvider(value)
} else {
options[key as keyof ApiHandlerOptions] = value
}
assignMaskedOption(options, key, value)
// If mode configs should be synced, also update the alternate mode field
if (!separateModeConfigs) {
const alternateField = getAlternateModeField(key)
if (alternateField) {
if (alternateField === "planModeApiProvider") {
options.planModeApiProvider = convertProtoToApiProvider(value)
} else if (alternateField === "actModeApiProvider") {
options.actModeApiProvider = convertProtoToApiProvider(value)
} else {
options[alternateField as keyof ApiHandlerOptions] = value
}
}
const alternateField = getAlternateModeField(key)
if (alternateField) {
assignMaskedOption(options, alternateField, value)
}
}
}
@@ -1,17 +1,11 @@
import { Empty } from "@shared/proto/cline/common"
import type { UpdateApiConfigurationRequest } from "@shared/proto/cline/models"
import { convertProtoToApiProvider } from "@shared/proto-conversions/models/api-configuration-conversion"
import {
fromProtobufLiteLLMModelInfo,
fromProtobufModelInfo,
fromProtobufOcaModelInfo,
fromProtobufOpenAiCompatibleModelInfo,
} from "@shared/proto-conversions/models/typeConversion"
import { OpenaiReasoningEffort } from "@shared/storage/types"
import { convertProtoToApiConfiguration } from "@shared/proto-conversions/models/api-configuration-conversion"
import { Logger } from "@/shared/services/Logger"
import type { Controller } from "../index"
import { clearOrganizationForClinePassProviderSelection } from "./handleClinePassProviderSelection"
import { normalizeProviderSwitchModel } from "./providerSwitchNormalization"
import { mirrorPlanActApiConfiguration } from "./sharedModeConfiguration"
import { createTaskApiModelShim, resolveActiveModelIdFromApiConfiguration } from "./taskApiModel"
/**
@@ -30,107 +24,9 @@ export async function updateApiConfigurationProto(
throw new Error("API configuration is required")
}
const protoApiConfiguration = request.apiConfiguration
const convertedApiConfigurationFromProto = {
...protoApiConfiguration,
// Convert proto ApiProvider enums to native string types
planModeApiProvider:
protoApiConfiguration.planModeApiProvider !== undefined
? convertProtoToApiProvider(protoApiConfiguration.planModeApiProvider!)
: undefined,
actModeApiProvider:
protoApiConfiguration.actModeApiProvider !== undefined
? convertProtoToApiProvider(protoApiConfiguration.actModeApiProvider!)
: undefined,
// Convert ModelInfo objects (empty arrays → undefined)
// Plan Mode
planModeOpenRouterModelInfo: protoApiConfiguration.planModeOpenRouterModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeOpenRouterModelInfo)
: undefined,
planModeClineModelInfo: protoApiConfiguration.planModeClineModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeClineModelInfo)
: undefined,
planModeClinePassModelInfo: protoApiConfiguration.planModeClinePassModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeClinePassModelInfo)
: undefined,
planModeOpenAiModelInfo: protoApiConfiguration.planModeOpenAiModelInfo
? fromProtobufOpenAiCompatibleModelInfo(protoApiConfiguration.planModeOpenAiModelInfo)
: undefined,
planModeHuggingFaceModelInfo: protoApiConfiguration.planModeHuggingFaceModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeHuggingFaceModelInfo)
: undefined,
planModeLiteLlmModelInfo: protoApiConfiguration.planModeLiteLlmModelInfo
? fromProtobufLiteLLMModelInfo(protoApiConfiguration.planModeLiteLlmModelInfo)
: undefined,
planModeRequestyModelInfo: protoApiConfiguration.planModeRequestyModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeRequestyModelInfo)
: undefined,
planModeGroqModelInfo: protoApiConfiguration.planModeGroqModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeGroqModelInfo)
: undefined,
planModeHuaweiCloudMaasModelInfo: protoApiConfiguration.planModeHuaweiCloudMaasModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeHuaweiCloudMaasModelInfo)
: undefined,
planModeBasetenModelInfo: protoApiConfiguration.planModeBasetenModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeBasetenModelInfo)
: undefined,
planModeVercelAiGatewayModelInfo: protoApiConfiguration.planModeVercelAiGatewayModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeVercelAiGatewayModelInfo)
: undefined,
planModeOcaModelInfo: protoApiConfiguration.planModeOcaModelInfo
? fromProtobufOcaModelInfo(protoApiConfiguration.planModeOcaModelInfo)
: undefined,
planModeAihubmixModelInfo: protoApiConfiguration.planModeAihubmixModelInfo
? fromProtobufOpenAiCompatibleModelInfo(protoApiConfiguration.planModeAihubmixModelInfo)
: undefined,
// Act Mode
actModeOpenRouterModelInfo: protoApiConfiguration.actModeOpenRouterModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeOpenRouterModelInfo)
: undefined,
actModeClineModelInfo: protoApiConfiguration.actModeClineModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeClineModelInfo)
: undefined,
actModeClinePassModelInfo: protoApiConfiguration.actModeClinePassModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeClinePassModelInfo)
: undefined,
actModeOpenAiModelInfo: protoApiConfiguration.actModeOpenAiModelInfo
? fromProtobufOpenAiCompatibleModelInfo(protoApiConfiguration.actModeOpenAiModelInfo)
: undefined,
actModeLiteLlmModelInfo: protoApiConfiguration.actModeLiteLlmModelInfo
? fromProtobufLiteLLMModelInfo(protoApiConfiguration.actModeLiteLlmModelInfo)
: undefined,
actModeRequestyModelInfo: protoApiConfiguration.actModeRequestyModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeRequestyModelInfo)
: undefined,
actModeGroqModelInfo: protoApiConfiguration.actModeGroqModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeGroqModelInfo)
: undefined,
actModeHuggingFaceModelInfo: protoApiConfiguration.actModeHuggingFaceModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeHuggingFaceModelInfo)
: undefined,
actModeHuaweiCloudMaasModelInfo: protoApiConfiguration.actModeHuaweiCloudMaasModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeHuaweiCloudMaasModelInfo)
: undefined,
actModeBasetenModelInfo: protoApiConfiguration.actModeBasetenModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeBasetenModelInfo)
: undefined,
actModeVercelAiGatewayModelInfo: protoApiConfiguration.actModeVercelAiGatewayModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeVercelAiGatewayModelInfo)
: undefined,
actModeOcaModelInfo: protoApiConfiguration.actModeOcaModelInfo
? fromProtobufOcaModelInfo(protoApiConfiguration.actModeOcaModelInfo)
: undefined,
actModeAihubmixModelInfo: protoApiConfiguration.actModeAihubmixModelInfo
? fromProtobufOpenAiCompatibleModelInfo(protoApiConfiguration.actModeAihubmixModelInfo)
: undefined,
geminiPlanModeThinkingLevel: protoApiConfiguration.geminiPlanModeThinkingLevel,
geminiActModeThinkingLevel: protoApiConfiguration.geminiActModeThinkingLevel,
planModeReasoningEffort: protoApiConfiguration.planModeReasoningEffort as OpenaiReasoningEffort | undefined,
actModeReasoningEffort: protoApiConfiguration.actModeReasoningEffort as OpenaiReasoningEffort | undefined,
}
const convertedApiConfigurationFromProto = mirrorPlanActApiConfiguration(
convertProtoToApiConfiguration(request.apiConfiguration),
)
const previousApiConfiguration = controller.stateManager.getApiConfiguration()
const normalizedApiConfiguration = normalizeProviderSwitchModel(
@@ -1,3 +1,6 @@
import type { ProviderConfigPatch } from "@/sdk/model-catalog/contracts"
import { areProviderIdsEquivalent } from "@/shared/model-catalog/provider-helpers"
import { getRemoteLockedProviderFieldPaths } from "@/shared/model-catalog/remote-config-locks"
import { ProviderConfigResponse, WriteProviderConfigRequest } from "@/shared/proto/cline/models"
import {
type ProviderCatalogController,
@@ -6,12 +9,300 @@ import {
toRedactedProviderConfigResponse,
} from "./providerCatalogShared"
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function readString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined
}
function readBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined
}
function readNumber(value: unknown): number | undefined {
return typeof value === "number" ? value : undefined
}
function readMode(value: unknown): ProviderConfigPatch["mode"] {
return value === "plan" || value === "act" ? value : undefined
}
function readHeaders(value: unknown): Readonly<Record<string, string>> | undefined {
if (!isRecord(value)) {
return undefined
}
const headers: Record<string, string> = {}
for (const [key, headerValue] of Object.entries(value)) {
if (typeof headerValue !== "string") {
return undefined
}
headers[key] = headerValue
}
return headers
}
function stripInternalSettings(settings: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(settings).filter(([key]) => !key.startsWith("__")))
}
function isSettingsObjectEmpty(settings: Record<string, unknown>): boolean {
return Object.keys(stripInternalSettings(settings)).length === 0
}
function toSettingsProviderConfigPatch(settings: Record<string, unknown>): ProviderConfigPatch {
const mode = readMode(settings.__mode)
const providerSettings = stripInternalSettings(settings)
const aws = isRecord(providerSettings.aws) ? providerSettings.aws : undefined
const gcp = isRecord(providerSettings.gcp) ? providerSettings.gcp : undefined
const azure = isRecord(providerSettings.azure) ? providerSettings.azure : undefined
const sap = isRecord(providerSettings.sap) ? providerSettings.sap : undefined
const oca = isRecord(providerSettings.oca) ? providerSettings.oca : undefined
const auth = isRecord(providerSettings.auth) ? providerSettings.auth : undefined
const reasoning = isRecord(providerSettings.reasoning) ? providerSettings.reasoning : undefined
return {
...(mode ? { mode } : {}),
settings: providerSettings,
...(providerSettings.apiKey !== undefined ? { apiKey: readString(providerSettings.apiKey) ?? null } : {}),
...(providerSettings.baseUrl !== undefined ? { baseUrl: readString(providerSettings.baseUrl) ?? null } : {}),
...(providerSettings.apiLine !== undefined ? { apiLine: readString(providerSettings.apiLine) ?? null } : {}),
...(providerSettings.region !== undefined ? { region: readString(providerSettings.region) ?? null } : {}),
...(providerSettings.headers !== undefined ? { headers: readHeaders(providerSettings.headers) ?? null } : {}),
...(aws
? {
aws: {
...(aws.accessKey !== undefined ? { accessKey: readString(aws.accessKey) } : {}),
...(aws.secretKey !== undefined ? { secretKey: readString(aws.secretKey) } : {}),
...(aws.sessionToken !== undefined ? { sessionToken: readString(aws.sessionToken) } : {}),
...(aws.region !== undefined ? { region: readString(aws.region) } : {}),
...(aws.authentication !== undefined ? { authentication: readString(aws.authentication) } : {}),
...(aws.profile !== undefined ? { profile: readString(aws.profile) } : {}),
...(aws.usePromptCache !== undefined ? { usePromptCache: readBoolean(aws.usePromptCache) } : {}),
...(aws.endpoint !== undefined ? { endpoint: readString(aws.endpoint) } : {}),
...(aws.customModelBaseId !== undefined ? { customModelBaseId: readString(aws.customModelBaseId) } : {}),
...(aws.useCrossRegionInference !== undefined
? { useCrossRegionInference: readBoolean(aws.useCrossRegionInference) }
: {}),
...(aws.useGlobalInference !== undefined
? { useGlobalInference: readBoolean(aws.useGlobalInference) }
: {}),
},
}
: {}),
...(gcp
? {
gcp: {
...(gcp.projectId !== undefined ? { projectId: readString(gcp.projectId) } : {}),
...(gcp.region !== undefined ? { region: readString(gcp.region) } : {}),
},
}
: {}),
...(azure
? {
azure: {
...(azure.apiVersion !== undefined ? { apiVersion: readString(azure.apiVersion) } : {}),
},
}
: {}),
...(sap
? {
sap: {
...(sap.clientId !== undefined ? { clientId: readString(sap.clientId) } : {}),
...(sap.clientSecret !== undefined ? { clientSecret: readString(sap.clientSecret) } : {}),
...(sap.tokenUrl !== undefined ? { tokenUrl: readString(sap.tokenUrl) } : {}),
...(sap.resourceGroup !== undefined ? { resourceGroup: readString(sap.resourceGroup) } : {}),
...(sap.deploymentId !== undefined ? { deploymentId: readString(sap.deploymentId) } : {}),
...(sap.useOrchestrationMode !== undefined
? { useOrchestrationMode: readBoolean(sap.useOrchestrationMode) }
: {}),
...(sap.api !== undefined ? { api: readString(sap.api) } : {}),
...(isRecord(sap.defaultSettings) ? { defaultSettings: sap.defaultSettings } : {}),
},
}
: {}),
...(oca
? {
oca: {
...(oca.mode !== undefined ? { mode: readString(oca.mode) } : {}),
...(oca.usePromptCache !== undefined ? { usePromptCache: readBoolean(oca.usePromptCache) } : {}),
},
}
: {}),
...(auth
? {
auth: {
accessToken: readString(auth.accessToken),
refreshToken: readString(auth.refreshToken),
accountId: readString(auth.accountId),
},
}
: {}),
...(reasoning
? {
reasoning: {
enabled: readBoolean(reasoning.enabled),
effort: readString(reasoning.effort),
budgetTokens: readNumber(reasoning.budgetTokens),
},
}
: {}),
...(isRecord(providerSettings.extras) ? { extras: providerSettings.extras } : {}),
}
}
function deletePath(settings: Record<string, unknown>, path: string): void {
const segments = path.split(".").filter(Boolean)
if (segments.length === 0) {
return
}
const parents: Array<[Record<string, unknown>, string]> = []
let cursor: Record<string, unknown> = settings
for (const segment of segments.slice(0, -1)) {
const next = cursor[segment]
if (!isRecord(next)) {
return
}
parents.push([cursor, segment])
cursor = next
}
delete cursor[segments[segments.length - 1]]
for (const [parent, segment] of parents.reverse()) {
const child = parent[segment]
if (isRecord(child) && Object.keys(child).length === 0) {
delete parent[segment]
}
}
}
type RemoteConfigController = ProviderCatalogController & {
stateManager?: {
getRemoteConfigSettings?: () => unknown
}
}
function stripLockedSettings(
controller: ProviderCatalogController,
providerId: string,
settings: Record<string, unknown>,
): Record<string, unknown> {
const remoteConfigSettings = (controller as RemoteConfigController).stateManager?.getRemoteConfigSettings?.()
if (!isRecord(remoteConfigSettings)) {
return settings
}
const lockedPaths = getRemoteLockedProviderFieldPaths(remoteConfigSettings, providerId)
if (lockedPaths.size === 0) {
return settings
}
const next = structuredClone(settings) as Record<string, unknown>
for (const path of lockedPaths) {
deletePath(next, path)
}
return next
}
function pickAdvertisedSettingsPaths(
settings: Record<string, unknown>,
allowedPaths: ReadonlySet<string>,
parentPath = "",
): Record<string, unknown> {
const next: Record<string, unknown> = {}
for (const [key, value] of Object.entries(settings)) {
if (key.startsWith("__")) {
next[key] = value
continue
}
const path = parentPath ? `${parentPath}.${key}` : key
if (allowedPaths.has(path)) {
next[key] = value
continue
}
if (!isRecord(value)) {
continue
}
const hasAllowedChildPath = [...allowedPaths].some((allowedPath) => allowedPath.startsWith(`${path}.`))
if (!hasAllowedChildPath) {
continue
}
const child = pickAdvertisedSettingsPaths(value, allowedPaths, path)
if (Object.keys(child).length > 0) {
next[key] = child
}
}
return next
}
async function stripUnadvertisedSettings(
controller: ProviderCatalogController,
providerId: string,
settings: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const listing = (await controller.getProviderCatalog().listProviders()).find((provider) =>
areProviderIdsEquivalent(provider.id, providerId),
)
const allowedPaths = new Set(listing?.configFields?.map((field) => field.path) ?? [])
if (allowedPaths.size === 0) {
return Object.fromEntries(Object.entries(settings).filter(([key]) => key.startsWith("__")))
}
return pickAdvertisedSettingsPaths(settings, allowedPaths)
}
async function providerAdvertisesFieldPath(
controller: ProviderCatalogController,
providerId: string,
path: string,
): Promise<boolean> {
const listing = (await controller.getProviderCatalog().listProviders()).find((provider) =>
areProviderIdsEquivalent(provider.id, providerId),
)
return listing?.configFields?.some((field) => field.path === path) === true
}
async function stripUnsafeDirectPatch(
controller: ProviderCatalogController,
providerId: string,
patch: ProviderConfigPatch,
): Promise<ProviderConfigPatch> {
const { auth: _auth, ...next } = patch
if ("headers" in next && !(await providerAdvertisesFieldPath(controller, providerId, "headers"))) {
const { headers: _headers, ...withoutHeaders } = next
return withoutHeaders
}
return next
}
export async function writeProviderConfig(
controller: ProviderCatalogController,
request: WriteProviderConfigRequest,
): Promise<ProviderConfigResponse> {
const providerId = parseProviderIdRequest(request.providerId)
const store = controller.getProviderConfigStore()
const updated = store.write(providerId, toProviderConfigPatch(request.patch))
const settingsJson = request.patch?.settingsJson?.trim()
if (settingsJson) {
const parsed = JSON.parse(settingsJson) as unknown
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("settings_json must be a JSON object")
}
const advertisedSettings = await stripUnadvertisedSettings(controller, providerId, parsed as Record<string, unknown>)
const settings = stripLockedSettings(controller, providerId, advertisedSettings)
if (isSettingsObjectEmpty(settings)) {
return toRedactedProviderConfigResponse(store.read(providerId), store)
}
const updated = store.write(providerId, toSettingsProviderConfigPatch(settings))
return toRedactedProviderConfigResponse(updated, store)
}
const updated = store.write(
providerId,
await stripUnsafeDirectPatch(controller, providerId, toProviderConfigPatch(request.patch)),
)
return toRedactedProviderConfigResponse(updated, store)
}
@@ -0,0 +1,53 @@
import { ModelsApiConfiguration } from "@shared/proto/cline/models"
import { UpdateSettingsRequest } from "@shared/proto/cline/state"
import { describe, expect, it, vi } from "vitest"
import type { ProviderConfigStore } from "@/sdk/model-catalog/contracts"
import { updateSettings } from "../updateSettings"
function makeStore(): ProviderConfigStore {
return {
read: vi.fn((providerId) => ({ providerId })),
readSelection: vi.fn(() => undefined),
subscribe: vi.fn(() => ({ dispose: vi.fn() })),
write: vi.fn((providerId) => ({ providerId })),
commitSelection: vi.fn(),
}
}
describe("updateSettings", () => {
it("honors SDK provider id string fields in full API configuration updates", async () => {
const setApiConfiguration = vi.fn()
const previousConfig = { actModeApiProvider: "anthropic" }
const controller = {
getProviderConfigStore: () => makeStore(),
stateManager: {
getApiConfiguration: vi.fn(() => previousConfig),
getGlobalSettingsKey: vi.fn(() => "act"),
setApiConfiguration,
},
handleApiConfigurationChanged: vi.fn(),
postStateToWebview: vi.fn(async () => undefined),
} as any
await updateSettings(
controller,
UpdateSettingsRequest.create({
apiConfiguration: ModelsApiConfiguration.create({
actModeApiProviderId: "poolside",
}),
}),
)
expect(setApiConfiguration).toHaveBeenCalledWith(
expect.objectContaining({
actModeApiProvider: "poolside",
}),
)
expect(controller.handleApiConfigurationChanged).toHaveBeenCalledWith(
previousConfig,
expect.objectContaining({
actModeApiProvider: "poolside",
}),
)
})
})
@@ -8,11 +8,18 @@ import { getHooksEnabledSafe } from "@core/hooks/hooks-utils"
import type { ExtensionState, Platform } from "@shared/ExtensionMessage"
import { ClineEnv } from "@/config"
import { ExtensionRegistryInfo } from "@/registry"
import type { ProviderConfigStore } from "@/sdk/model-catalog/contracts"
import { parseProviderId } from "@/sdk/model-catalog/provider-id"
import { BannerService } from "@/services/banner/BannerService"
import { featureFlagsService } from "@/services/feature-flags"
import { getDistinctId } from "@/services/logging/distinctId"
import type { ApiConfiguration, ApiProvider } from "@/shared/api"
import { toVscodeSupportedProvider } from "@/shared/model-catalog/provider-helpers"
import { getProviderModelIdKey } from "@/shared/storage/provider-keys"
import type { SettingsKey } from "@/shared/storage/state-keys"
import { getLatestAnnouncementId } from "@/utils/announcements"
import { getClineOnboardingModels } from "../models/getClineOnboardingModels"
import { ensureSharedModeApiConfiguration } from "../models/sharedModeConfiguration"
/**
* Builds the ExtensionState object to push to the webview.
@@ -25,12 +32,13 @@ export async function getStateToPostToWebview(controller: {
backgroundCommandRunning?: boolean
backgroundCommandTaskId?: string
workspaceManager?: any
getProviderConfigStore?(): ProviderConfigStore
}): Promise<ExtensionState> {
const stateManager = controller.stateManager
// Get API configuration from cache for immediate access
const onboardingModels = getClineOnboardingModels()
const apiConfiguration = stateManager.getApiConfiguration()
const apiConfiguration = hydrateApiConfigurationFromSdkSelection(controller, ensureSharedModeApiConfiguration(controller))
const lastShownAnnouncementId = stateManager.getGlobalStateKey("lastShownAnnouncementId")
const taskHistory = stateManager.getGlobalStateKey("taskHistory")
const autoApprovalSettings = stateManager.getGlobalSettingsKey("autoApprovalSettings")
@@ -44,7 +52,6 @@ export async function getStateToPostToWebview(controller: {
const mcpMarketplaceEnabled = stateManager.getGlobalStateKey("mcpMarketplaceEnabled")
const mcpDisplayMode = stateManager.getGlobalStateKey("mcpDisplayMode")
const telemetrySetting = stateManager.getGlobalSettingsKey("telemetrySetting")
const planActSeparateModelsSetting = stateManager.getGlobalSettingsKey("planActSeparateModelsSetting")
const enableCheckpointsSetting = stateManager.getGlobalSettingsKey("enableCheckpointsSetting")
const globalClineRulesToggles = stateManager.getGlobalStateKey("globalClineRulesToggles")
const globalWorkflowToggles = stateManager.getGlobalStateKey("globalWorkflowToggles")
@@ -124,7 +131,7 @@ export async function getStateToPostToWebview(controller: {
mcpMarketplaceEnabled,
mcpDisplayMode,
telemetrySetting,
planActSeparateModelsSetting,
planActSeparateModelsSetting: false,
enableCheckpointsSetting: enableCheckpointsSetting ?? true,
platform,
environment,
@@ -188,3 +195,36 @@ export async function getStateToPostToWebview(controller: {
openAiCodexIsAuthenticated,
} as ExtensionState
}
function hydrateApiConfigurationFromSdkSelection(
controller: { getProviderConfigStore?(): ProviderConfigStore },
config: ApiConfiguration,
): ApiConfiguration {
const store = controller.getProviderConfigStore?.()
if (!store) {
return config
}
const next: ApiConfiguration = { ...config }
for (const mode of ["plan", "act"] as const) {
const provider = mode === "plan" ? next.planModeApiProvider : next.actModeApiProvider
if (!provider) {
continue
}
const supportedProvider = toVscodeSupportedProvider(provider)
const selection = store.readSelection(parseProviderId(supportedProvider), mode)
if (!selection?.modelId) {
continue
}
const modelKey = getProviderModelIdKey(supportedProvider as ApiProvider, mode)
setApiConfigurationString(next, modelKey, selection.modelId)
}
return next
}
function setApiConfigurationString(config: ApiConfiguration, key: SettingsKey, value: string): void {
;(config as Partial<Record<SettingsKey, string>>)[key] = value
}
@@ -1,7 +1,6 @@
import { Empty } from "@shared/proto/cline/common"
import { PlanActMode, McpDisplayMode as ProtoMcpDisplayMode, UpdateSettingsRequest } from "@shared/proto/cline/state"
import { convertProtoToApiProvider } from "@shared/proto-conversions/models/api-configuration-conversion"
import { OpenaiReasoningEffort } from "@shared/storage/types"
import { convertProtoToApiConfiguration } from "@shared/proto-conversions/models/api-configuration-conversion"
import { TelemetrySetting } from "@shared/TelemetrySetting"
import { ClineEnv } from "@/config"
import { fetchRemoteConfig } from "@/core/storage/remote-config/fetch"
@@ -13,6 +12,7 @@ import { BrowserSettings as SharedBrowserSettings } from "../../../shared/Browse
import { Controller } from ".."
import { accountLogoutClicked } from "../account/accountLogoutClicked"
import { normalizeProviderSwitchModel } from "../models/providerSwitchNormalization"
import { mirrorPlanActApiConfiguration } from "../models/sharedModeConfiguration"
import { createTaskApiModelShim, resolveActiveModelIdFromApiConfiguration } from "../models/taskApiModel"
/**
@@ -31,18 +31,9 @@ export async function updateSettings(controller: Controller, request: UpdateSett
if (request.apiConfiguration) {
const protoApiConfiguration = request.apiConfiguration
const convertedApiConfigurationFromProto = {
...protoApiConfiguration,
// Convert proto ApiProvider enums to native string types
planModeApiProvider: protoApiConfiguration.planModeApiProvider
? convertProtoToApiProvider(protoApiConfiguration.planModeApiProvider)
: undefined,
actModeApiProvider: protoApiConfiguration.actModeApiProvider
? convertProtoToApiProvider(protoApiConfiguration.actModeApiProvider)
: undefined,
planModeReasoningEffort: protoApiConfiguration.planModeReasoningEffort as OpenaiReasoningEffort | undefined,
actModeReasoningEffort: protoApiConfiguration.actModeReasoningEffort as OpenaiReasoningEffort | undefined,
}
const convertedApiConfigurationFromProto = mirrorPlanActApiConfiguration(
convertProtoToApiConfiguration(protoApiConfiguration),
)
const previousApiConfiguration = controller.stateManager.getApiConfiguration()
const normalizedApiConfiguration = normalizeProviderSwitchModel(
@@ -66,9 +57,8 @@ export async function updateSettings(controller: Controller, request: UpdateSett
await controller.updateTelemetrySetting(request.telemetrySetting as TelemetrySetting)
}
// Update plan/act separate models setting
if (request.planActSeparateModelsSetting !== undefined) {
controller.stateManager.setGlobalState("planActSeparateModelsSetting", request.planActSeparateModelsSetting)
controller.stateManager.setGlobalState("planActSeparateModelsSetting", false)
}
// Update checkpoints setting
@@ -5,11 +5,11 @@ import { Logger } from "@/shared/services/Logger"
import { GlobalStateAndSettings } from "@/shared/storage/state-keys"
import type { Controller } from "../index"
import { refreshBasetenModels } from "../models/refreshBasetenModels"
import { refreshGroqModels } from "../models/refreshGroqModels"
import { refreshHicapModels } from "../models/refreshHicapModels"
import { refreshLiteLlmModels } from "../models/refreshLiteLlmModels"
import { refreshOpenRouterModels } from "../models/refreshOpenRouterModels"
import { ensureSharedModeApiConfiguration } from "../models/sharedModeConfiguration"
import { sendOpenRouterModelsEvent } from "../models/subscribeToOpenRouterModels"
/**
@@ -20,6 +20,8 @@ import { sendOpenRouterModelsEvent } from "../models/subscribeToOpenRouterModels
*/
export async function initializeWebview(controller: Controller, _request: EmptyRequest): Promise<Empty> {
try {
ensureSharedModeApiConfiguration(controller)
// Post last cached models as soon as possible for immediate availability in the UI
const lastCachedModels = await controller.readOpenRouterModels()
if (lastCachedModels) {
@@ -31,40 +33,19 @@ export async function initializeWebview(controller: Controller, _request: EmptyR
if (models && Object.keys(models).length > 0) {
// Update model info in state (this needs to be done here since we don't want to update state while settings is open, and we may refresh models there)
const apiConfiguration = controller.stateManager.getApiConfiguration()
const planActSeparateModelsSetting = controller.stateManager.getGlobalSettingsKey("planActSeparateModelsSetting")
const currentMode = controller.stateManager.getGlobalSettingsKey("mode")
const planModelId = apiConfiguration.planModeOpenRouterModelId
const actModelId = apiConfiguration.actModeOpenRouterModelId
const updates: Partial<GlobalStateAndSettings> = {}
if (planActSeparateModelsSetting) {
// Separate models: update only current mode
const modelIdField = currentMode === "plan" ? "planModeOpenRouterModelId" : "actModeOpenRouterModelId"
const modelInfoField = currentMode === "plan" ? "planModeOpenRouterModelInfo" : "actModeOpenRouterModelInfo"
const modelId = apiConfiguration[modelIdField]
if (modelId && models[modelId]) {
controller.stateManager.setGlobalState(modelInfoField, models[modelId])
await controller.postStateToWebview()
}
} else {
// Shared models: update both plan and act modes
const planModelId = apiConfiguration.planModeOpenRouterModelId
const actModelId = apiConfiguration.actModeOpenRouterModelId
const updates: Partial<GlobalStateAndSettings> = {}
// Update plan mode model info if we have a model ID
if (planModelId && models[planModelId]) {
updates.planModeOpenRouterModelInfo = models[planModelId]
}
// Update act mode model info if we have a model ID
if (actModelId && models[actModelId]) {
updates.actModeOpenRouterModelInfo = models[actModelId]
}
// Post state update if we updated any model info
if (Object.keys(updates).length > 0) {
controller.stateManager.setGlobalStateBatch(updates)
await controller.postStateToWebview()
}
if (planModelId && models[planModelId]) {
updates.planModeOpenRouterModelInfo = models[planModelId]
}
if (actModelId && models[actModelId]) {
updates.actModeOpenRouterModelInfo = models[actModelId]
}
if (Object.keys(updates).length > 0) {
controller.stateManager.setGlobalStateBatch(updates)
await controller.postStateToWebview()
}
}
})
@@ -73,40 +54,19 @@ export async function initializeWebview(controller: Controller, _request: EmptyR
if (models && Object.keys(models).length > 0) {
// Update model info in state for Groq (this needs to be done here since we don't want to update state while settings is open, and we may refresh models there)
const apiConfiguration = controller.stateManager.getApiConfiguration()
const planActSeparateModelsSetting = controller.stateManager.getGlobalSettingsKey("planActSeparateModelsSetting")
const currentMode = controller.stateManager.getGlobalSettingsKey("mode")
const planModelId = apiConfiguration.planModeGroqModelId
const actModelId = apiConfiguration.actModeGroqModelId
const updates: Partial<GlobalStateAndSettings> = {}
if (planActSeparateModelsSetting) {
// Separate models: update only current mode
const modelIdField = currentMode === "plan" ? "planModeGroqModelId" : "actModeGroqModelId"
const modelInfoField = currentMode === "plan" ? "planModeGroqModelInfo" : "actModeGroqModelInfo"
const modelId = apiConfiguration[modelIdField]
if (modelId && models[modelId]) {
controller.stateManager.setGlobalState(modelInfoField, models[modelId])
await controller.postStateToWebview()
}
} else {
// Shared models: update both plan and act modes
const planModelId = apiConfiguration.planModeGroqModelId
const actModelId = apiConfiguration.actModeGroqModelId
const updates: Partial<GlobalStateAndSettings> = {}
// Update plan mode model info if we have a model ID
if (planModelId && models[planModelId]) {
updates.planModeGroqModelInfo = models[planModelId]
}
// Update act mode model info if we have a model ID
if (actModelId && models[actModelId]) {
updates.actModeGroqModelInfo = models[actModelId]
}
// Post state update if we updated any model info
if (Object.keys(updates).length > 0) {
controller.stateManager.setGlobalStateBatch(updates)
await controller.postStateToWebview()
}
if (planModelId && models[planModelId]) {
updates.planModeGroqModelInfo = models[planModelId]
}
if (actModelId && models[actModelId]) {
updates.actModeGroqModelInfo = models[actModelId]
}
if (Object.keys(updates).length > 0) {
controller.stateManager.setGlobalStateBatch(updates)
await controller.postStateToWebview()
}
}
})
@@ -115,39 +75,19 @@ export async function initializeWebview(controller: Controller, _request: EmptyR
if (models && Object.keys(models).length > 0) {
// Update model info in state for Baseten (this needs to be done here since we don't want to update state while settings is open, and we may refresh models there)
const apiConfiguration = controller.stateManager.getApiConfiguration()
const planActSeparateModelsSetting = controller.stateManager.getGlobalSettingsKey("planActSeparateModelsSetting")
const planModelId = apiConfiguration.planModeBasetenModelId
const actModelId = apiConfiguration.actModeBasetenModelId
const updates: Partial<GlobalStateAndSettings> = {}
const currentMode = controller.stateManager.getGlobalSettingsKey("mode")
if (planActSeparateModelsSetting) {
// Separate models: update only current mode
const modelIdField = currentMode === "plan" ? "planModeBasetenModelId" : "actModeBasetenModelId"
const modelInfoField = currentMode === "plan" ? "planModeBasetenModelInfo" : "actModeBasetenModelInfo"
const modelId = apiConfiguration[modelIdField]
if (modelId && models[modelId]) {
controller.stateManager.setGlobalState(modelInfoField, models[modelId])
await controller.postStateToWebview()
}
} else {
// Shared models: update both plan and act modes
const planModelId = apiConfiguration.planModeBasetenModelId
const actModelId = apiConfiguration.actModeBasetenModelId
// Update plan mode model info if we have a model ID
if (planModelId && models[planModelId]) {
controller.stateManager.setGlobalState("planModeBasetenModelInfo", models[planModelId])
}
// Update act mode model info if we have a model ID
if (actModelId && models[actModelId]) {
controller.stateManager.setGlobalState("actModeBasetenModelInfo", models[actModelId])
}
// Post state update if we updated any model info
if ((planModelId && models[planModelId]) || (actModelId && models[actModelId])) {
await controller.postStateToWebview()
}
if (planModelId && models[planModelId]) {
updates.planModeBasetenModelInfo = models[planModelId]
}
if (actModelId && models[actModelId]) {
updates.actModeBasetenModelInfo = models[actModelId]
}
if (Object.keys(updates).length > 0) {
controller.stateManager.setGlobalStateBatch(updates)
await controller.postStateToWebview()
}
}
})
@@ -157,40 +97,19 @@ export async function initializeWebview(controller: Controller, _request: EmptyR
if (response && response.models) {
// Update model info in state (this needs to be done here since we don't want to update state while settings is open, and we may refresh models there)
const apiConfiguration = controller.stateManager.getApiConfiguration()
const planActSeparateModelsSetting = controller.stateManager.getGlobalSettingsKey("planActSeparateModelsSetting")
const currentMode = controller.stateManager.getGlobalSettingsKey("mode")
const planModelId = apiConfiguration.planModeHicapModelId
const actModelId = apiConfiguration.actModeHicapModelId
const updates: Partial<GlobalStateAndSettings> = {}
if (planActSeparateModelsSetting) {
// Separate models: update only current mode
const modelIdField = currentMode === "plan" ? "planModeHicapModelId" : "actModeHicapModelId"
const modelInfoField = currentMode === "plan" ? "planModeHicapModelInfo" : "actModeHicapModelInfo"
const modelId = apiConfiguration[modelIdField]
if (modelId && response.models[modelId]) {
controller.stateManager.setGlobalState(modelInfoField, response.models[modelId])
await controller.postStateToWebview()
}
} else {
// Shared models: update both plan and act modes
const planModelId = apiConfiguration.planModeHicapModelId
const actModelId = apiConfiguration.actModeHicapModelId
const updates: Partial<GlobalStateAndSettings> = {}
// Update plan mode model info if we have a model ID
if (planModelId && response.models[planModelId]) {
updates.planModeHicapModelInfo = response.models[planModelId]
}
// Update act mode model info if we have a model ID
if (actModelId && response.models[actModelId]) {
updates.actModeHicapModelInfo = response.models[actModelId]
}
// Post state update if we updated any model info
if ((planModelId && response.models[planModelId]) || (actModelId && response.models[actModelId])) {
controller.stateManager.setGlobalStateBatch(updates)
await controller.postStateToWebview()
}
if (planModelId && response.models[planModelId]) {
updates.planModeHicapModelInfo = response.models[planModelId]
}
if (actModelId && response.models[actModelId]) {
updates.actModeHicapModelInfo = response.models[actModelId]
}
if (Object.keys(updates).length > 0) {
controller.stateManager.setGlobalStateBatch(updates)
await controller.postStateToWebview()
}
}
})
@@ -198,7 +117,7 @@ export async function initializeWebview(controller: Controller, _request: EmptyR
const liteLlmBaseUrl = controller.stateManager.getGlobalSettingsKey("liteLlmBaseUrl")
const liteLlmApiKey = controller.stateManager.getSecretKey("liteLlmApiKey")
if (liteLlmBaseUrl && liteLlmApiKey) {
await refreshLiteLlmModels(controller)
await refreshLiteLlmModels()
}
// GUI relies on model info to be up-to-date to provide the most accurate pricing, so we need to fetch the latest details on launch.
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest"
import { transformRemoteConfigToStateShape } from "../utils"
describe("transformRemoteConfigToStateShape", () => {
it("preserves remote provider model allowlists and Bedrock custom models", () => {
const transformed = transformRemoteConfigToStateShape({
version: "v1",
providerSettings: {
OpenAiCompatible: {
models: [{ id: "openai-compatible-model", contextWindow: 128_000 }],
},
AwsBedrock: {
models: [{ id: "anthropic.claude-sonnet-4-6", thinkingBudgetTokens: 4096 }],
customModels: [
{
name: "application-inference-profile",
baseModelId: "anthropic.claude-sonnet-4-6",
thinkingBudgetTokens: 2048,
},
],
},
Vertex: {
models: [{ id: "claude-sonnet-4@20250514", thinkingBudgetTokens: 1024 }],
},
},
})
expect(transformed.remoteProviderModelSettings).toEqual({
"openai-compatible": {
models: [{ id: "openai-compatible-model", contextWindow: 128_000 }],
},
bedrock: {
models: [{ id: "anthropic.claude-sonnet-4-6", thinkingBudgetTokens: 4096 }],
bedrockCustomModels: [
{
name: "application-inference-profile",
baseModelId: "anthropic.claude-sonnet-4-6",
thinkingBudgetTokens: 2048,
},
],
},
vertex: {
models: [{ id: "claude-sonnet-4@20250514", thinkingBudgetTokens: 1024 }],
},
})
})
})
@@ -1,7 +1,12 @@
import { synchronizeRemoteRuleToggles } from "@core/context/instructions/user-instructions/rule-helpers"
import { parseRemoteSkillEntries } from "@core/context/instructions/user-instructions/skills"
import type { RemoteConfig, S3AccessKeySettings } from "@shared/remote-config/schema"
import { ConfiguredAPIKeys, GlobalStateAndSettings, RemoteConfigFields } from "@shared/storage/state-keys"
import {
ConfiguredAPIKeys,
GlobalStateAndSettings,
RemoteConfigFields,
type RemoteProviderModelSettings,
} from "@shared/storage/state-keys"
import { AuthService } from "@/services/auth/AuthService"
import { getDistinctId } from "@/services/logging/distinctId"
import { type McpHub } from "@/services/mcp/McpHub"
@@ -10,6 +15,7 @@ import { OpenTelemetryClientProvider } from "@/services/telemetry/providers/open
import { OpenTelemetryTelemetryProvider } from "@/services/telemetry/providers/opentelemetry/OpenTelemetryTelemetryProvider"
import { type TelemetryService } from "@/services/telemetry/TelemetryService"
import { ApiProvider } from "@/shared/api"
import { isProviderAllowedByRemoteConfig } from "@/shared/model-catalog/provider-helpers"
import { isOpenTelemetryConfigValid, remoteConfigToOtelConfig } from "@/shared/services/config/otel-config"
import { Logger } from "@/shared/services/Logger"
import { syncWorker } from "@/shared/services/worker/sync"
@@ -43,6 +49,7 @@ function accessSettingsToBlobStorage(type: BlobStoreSettings["adapterType"], set
*/
export function transformRemoteConfigToStateShape(remoteConfig: RemoteConfig): Partial<RemoteConfigFields> {
const transformed: Partial<RemoteConfigFields> = {}
const remoteProviderModelSettings: RemoteProviderModelSettings = {}
// Map top-level settings
if (remoteConfig.telemetryEnabled !== undefined) {
@@ -142,6 +149,9 @@ export function transformRemoteConfigToStateShape(remoteConfig: RemoteConfig): P
if (openAiSettings.azureIdentity !== undefined) {
transformed.azureIdentity = openAiSettings.azureIdentity
}
if (openAiSettings.models?.length) {
remoteProviderModelSettings["openai-compatible"] = { models: openAiSettings.models }
}
}
// Map AwsBedrock provider settings
@@ -166,6 +176,12 @@ export function transformRemoteConfigToStateShape(remoteConfig: RemoteConfig): P
if (awsBedrockSettings.awsBedrockEndpoint !== undefined) {
transformed.awsBedrockEndpoint = awsBedrockSettings.awsBedrockEndpoint
}
if (awsBedrockSettings.models?.length || awsBedrockSettings.customModels?.length) {
remoteProviderModelSettings.bedrock = {
...(awsBedrockSettings.models?.length ? { models: awsBedrockSettings.models } : {}),
...(awsBedrockSettings.customModels?.length ? { bedrockCustomModels: awsBedrockSettings.customModels } : {}),
}
}
}
const clineSettings = remoteConfig.providerSettings?.Cline
@@ -173,6 +189,9 @@ export function transformRemoteConfigToStateShape(remoteConfig: RemoteConfig): P
transformed.planModeApiProvider = "cline"
transformed.actModeApiProvider = "cline"
providers.push("cline")
if (clineSettings.models?.length) {
remoteProviderModelSettings.cline = { models: clineSettings.models }
}
}
// Map LiteLLM provider settings
@@ -185,6 +204,9 @@ export function transformRemoteConfigToStateShape(remoteConfig: RemoteConfig): P
if (liteLlmSettings.baseUrl !== undefined) {
transformed.liteLlmBaseUrl = liteLlmSettings.baseUrl
}
if (liteLlmSettings.models?.length) {
remoteProviderModelSettings.litellm = { models: liteLlmSettings.models }
}
}
// Map Vertex provider settings
@@ -200,6 +222,9 @@ export function transformRemoteConfigToStateShape(remoteConfig: RemoteConfig): P
if (vertexSettings.vertexRegion !== undefined) {
transformed.vertexRegion = vertexSettings.vertexRegion
}
if (vertexSettings.models?.length) {
remoteProviderModelSettings.vertex = { models: vertexSettings.models }
}
}
const anthropicSettings = remoteConfig.providerSettings?.Anthropic
@@ -211,12 +236,18 @@ export function transformRemoteConfigToStateShape(remoteConfig: RemoteConfig): P
if (anthropicSettings.baseUrl) {
transformed.anthropicBaseUrl = anthropicSettings.baseUrl
}
if (anthropicSettings.models?.length) {
remoteProviderModelSettings.anthropic = { models: anthropicSettings.models }
}
}
// This line needs to stay here, it is order dependent on the above code checking the configured providers
if (providers.length > 0) {
transformed.remoteConfiguredProviders = providers
}
if (Object.keys(remoteProviderModelSettings).length > 0) {
transformed.remoteProviderModelSettings = remoteProviderModelSettings
}
// Map global rules, workflows, and skills
if (remoteConfig.globalRules !== undefined) {
@@ -390,7 +421,7 @@ const isProviderValid = (provider?: ApiProvider, remoteConfig?: Partial<RemoteCo
return true
}
return provider && remoteConfiguredProviders.includes(provider)
return isProviderAllowedByRemoteConfig(provider, remoteConfiguredProviders)
}
/**
@@ -94,7 +94,8 @@ async function handleComputedProperties(result: any, stateValues: Map<string, an
result.planModeApiProvider = result.planModeApiProvider || defaultApiProvider
result.actModeApiProvider = result.actModeApiProvider || defaultApiProvider
// 2. Plan/Act separate models setting with special logic
// 2. Legacy separate-models flag. New SDK-native settings force this false,
// but old installs may still have a stored value until normalization runs.
const planActSeparateModelsSettingRaw = stateValues.get("planActSeparateModelsSetting")
if (planActSeparateModelsSettingRaw === true || planActSeparateModelsSettingRaw === false) {
result.planActSeparateModelsSetting = planActSeparateModelsSettingRaw
@@ -110,7 +110,7 @@ export abstract class WebviewProvider {
font-src ${this.getCspSource()} data:;
style-src ${this.getCspSource()} 'unsafe-inline';
img-src ${this.getCspSource()} https: data:;
script-src 'nonce-${nonce}' 'unsafe-eval' https://*.posthog.com https://*.cline.bot;">
script-src 'nonce-${nonce}' 'unsafe-eval';">
<title>Cline</title>
</head>
<body>
+8 -13
View File
@@ -41,6 +41,7 @@ import { ClineError } from "@/services/error/ClineError"
import { McpHub } from "@/services/mcp/McpHub"
import { telemetryService } from "@/services/telemetry"
import type { ClineExtensionContext } from "@/shared/cline"
import { areProviderIdsEquivalent } from "@/shared/model-catalog/provider-helpers"
import { ShowMessageRequest, ShowMessageType } from "@/shared/proto/host/window"
import { Logger } from "@/shared/services/Logger"
import { arePathsEqual, getDesktopDir } from "@/utils/path"
@@ -506,13 +507,13 @@ export class Controller {
return this.providerCatalog
}
invalidateProviderListings(): void {
this.providerCatalog.invalidateProviderListings()
}
private handleProviderConfigChange(event: ProviderConfigChange): void {
this.scheduleProviderConfigStatePost()
if (event.kind === "fields") {
this.providerChanges.handleProviderConfigFieldsChanged(event.providerId.toString())
}
if (event.kind === "selection" && this.isSelectionForActiveModeProvider(event)) {
this.sessions
?.updateActiveSessionModel(event.selection.modelId)
@@ -534,7 +535,7 @@ export class Controller {
const apiConfig = this.stateManager.getApiConfiguration()
const activeProvider = mode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider
return activeProvider === event.providerId.toString()
return areProviderIdsEquivalent(activeProvider, event.providerId.toString())
} catch {
return false
}
@@ -825,7 +826,6 @@ export class Controller {
)
const serializedError = clineError.serialize()
const failedAskTs = ts + 2
const messages: ClineMessage[] = [
{
ts,
@@ -844,7 +844,7 @@ export class Controller {
partial: false,
},
{
ts: failedAskTs,
ts: ts + 2,
type: "ask",
ask: "api_req_failed",
text: serializedError,
@@ -852,8 +852,6 @@ export class Controller {
},
]
this.turnStateTracker.set("error", failedAskTs)
this.messages.appendAndEmit(messages, {
type: "status",
payload: {
@@ -897,7 +895,6 @@ export class Controller {
message: rawErrorMessage,
})
const failedAskTs = ts + 1
const messages: ClineMessage[] = [
{
ts,
@@ -909,7 +906,7 @@ export class Controller {
partial: false,
},
{
ts: failedAskTs,
ts: ts + 1,
type: "ask",
ask: "api_req_failed",
text: serializedError,
@@ -917,8 +914,6 @@ export class Controller {
},
]
this.turnStateTracker.set("error", failedAskTs)
this.messages.appendAndEmit(messages, {
type: "status",
payload: {
@@ -0,0 +1,70 @@
import { ApiFormat, ModelsApiConfiguration, ApiProvider as ProtoApiProvider } from "@shared/proto/cline/models"
import { describe, expect, it } from "vitest"
import {
convertApiConfigurationToProto,
convertProtoToApiConfiguration,
} from "@/shared/proto-conversions/models/api-configuration-conversion"
describe("api configuration proto conversion", () => {
it("round-trips SDK-only provider ids through string provider fields", () => {
const proto = convertApiConfigurationToProto({
actModeApiProvider: "poolside" as any,
planModeApiProvider: "openai",
})
expect(proto.actModeApiProvider).toBeUndefined()
expect(proto.actModeApiProviderId).toBe("poolside")
expect(proto.planModeApiProvider).toBe(ProtoApiProvider.OPENAI)
expect(proto.planModeApiProviderId).toBe("openai")
const config = convertProtoToApiConfiguration(proto)
expect(config.actModeApiProvider).toBe("poolside")
expect(config.planModeApiProvider).toBe("openai")
})
it("prefers explicit enum provider updates over stale string provider ids", () => {
const config = convertProtoToApiConfiguration(
ModelsApiConfiguration.create({
actModeApiProvider: ProtoApiProvider.OPENAI,
actModeApiProviderId: "poolside",
}),
)
expect(config.actModeApiProvider).toBe("openai")
})
it("round-trips OCA model metadata without dropping thinking or temperature", () => {
const proto = convertApiConfigurationToProto({
actModeApiProvider: "oca",
actModeOcaModelId: "oca-model",
actModeOcaModelInfo: {
modelName: "OCA Test Model",
contextWindow: 200_000,
maxTokens: 8_192,
supportsImages: true,
supportsPromptCache: true,
supportsReasoning: true,
reasoningEffortOptions: ["low", "medium", "high"],
thinkingConfig: {
maxBudget: 16_384,
outputPrice: 1.23,
outputPriceTiers: [{ tokenLimit: 100_000, price: 2.34 }],
},
temperature: 0.2,
apiFormat: ApiFormat.OPENAI_CHAT,
},
})
const config = convertProtoToApiConfiguration(proto)
expect(config.actModeOcaModelInfo).toMatchObject({
thinkingConfig: {
maxBudget: 16_384,
outputPrice: 1.23,
outputPriceTiers: [{ tokenLimit: 100_000, price: 2.34 }],
},
temperature: 0.2,
})
})
})
+3 -13
View File
@@ -17,7 +17,6 @@ import { AuthService, type ClineAuthInfo, LogoutReason } from "./auth-service"
// ---------------------------------------------------------------------------
const mockFeatureFlagsPoll = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
const mockIdentifyAccount = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
// Mock StateManager
const mockSecrets = new Map<string, string>()
@@ -87,12 +86,6 @@ vi.mock("@/services/feature-flags", () => ({
},
}))
vi.mock("@/services/telemetry", () => ({
telemetryService: {
identifyAccount: mockIdentifyAccount,
},
}))
// Mock axios
vi.mock("axios", () => ({
default: {
@@ -483,7 +476,7 @@ describe("AuthService", () => {
describe("streaming subscriptions", () => {
it("subscribeToAuthStatusUpdate pushes initial state immediately", async () => {
const mockResponseStream = vi.fn()
const mockController = { postStateToWebview: vi.fn(), invalidateProviderListings: vi.fn() }
const mockController = { postStateToWebview: vi.fn() }
await authService.subscribeToAuthStatusUpdate(
// biome-ignore lint/suspicious/noExplicitAny: mock controller for testing
@@ -507,7 +500,7 @@ describe("AuthService", () => {
testAccess(authService)._authenticated = true
const mockResponseStream = vi.fn().mockResolvedValue(undefined)
const mockController = { postStateToWebview: vi.fn(), invalidateProviderListings: vi.fn() }
const mockController = { postStateToWebview: vi.fn() }
await authService.subscribeToAuthStatusUpdate(
// biome-ignore lint/suspicious/noExplicitAny: mock controller for testing
@@ -518,8 +511,6 @@ describe("AuthService", () => {
)
expect(mockFeatureFlagsPoll).toHaveBeenCalledWith("user-123")
expect(mockIdentifyAccount).toHaveBeenCalledWith(authInfo.userInfo)
expect(mockIdentifyAccount.mock.invocationCallOrder[0]).toBeLessThan(mockFeatureFlagsPoll.mock.invocationCallOrder[0])
expect(mockController.postStateToWebview).toHaveBeenCalled()
})
@@ -527,12 +518,11 @@ describe("AuthService", () => {
await authService.sendAuthStatusUpdate()
expect(mockFeatureFlagsPoll).toHaveBeenCalledWith(null)
expect(mockIdentifyAccount).not.toHaveBeenCalled()
})
it("removes subscription on cleanup", async () => {
const mockResponseStream = vi.fn().mockResolvedValue(undefined)
const mockController = { postStateToWebview: vi.fn(), invalidateProviderListings: vi.fn() }
const mockController = { postStateToWebview: vi.fn() }
await authService.subscribeToAuthStatusUpdate(
// biome-ignore lint/suspicious/noExplicitAny: mock controller for testing
+3 -12
View File
@@ -29,7 +29,6 @@ import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
import { BannerService } from "@/services/banner/BannerService"
import { buildBasicClineHeaders } from "@/services/EnvUtils"
import { featureFlagsService } from "@/services/feature-flags"
import { telemetryService } from "@/services/telemetry"
import { CLINE_API_ENDPOINT } from "@/shared/cline/api"
import { fetch, getAxiosSettings } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
@@ -945,7 +944,7 @@ export class AuthService {
* Send an authStatusUpdate event to all active subscribers.
*/
async sendAuthStatusUpdate(): Promise<void> {
const authState: AuthState = this.getInfo()
const authInfo: AuthState = this.getInfo()
const uniqueControllers = new Set<Controller>()
const streamSends = Array.from(this._activeAuthStatusUpdateHandlers).map(async (responseStream) => {
@@ -954,7 +953,7 @@ export class AuthService {
uniqueControllers.add(controller)
}
try {
await responseStream(authState, false)
await responseStream(authInfo, false)
} catch (error) {
Logger.error("[SdkAuthService] Error sending authStatusUpdate event:", error)
this._activeAuthStatusUpdateHandlers.delete(responseStream)
@@ -966,15 +965,7 @@ export class AuthService {
// Poll feature flags immediately for the current auth context so cache-only
// consumers (for example BannerService) see the latest remote config.
const authInfo = this._clineAuthInfo
if (authInfo?.userInfo) {
await telemetryService.identifyAccount(authInfo.userInfo)
}
const userId = authInfo?.userInfo?.id || null
await featureFlagsService.poll(userId)
for (const controller of uniqueControllers) {
controller.invalidateProviderListings()
}
await featureFlagsService.poll(this._clineAuthInfo?.userInfo?.id || null)
// Update state in webviews once per unique controller
await Promise.all(Array.from(uniqueControllers).map((c) => c.postStateToWebview()))
-181
View File
@@ -1,181 +0,0 @@
import type { ApiConfiguration } from "@shared/api"
import { describe, expect, it } from "vitest"
import { buildBedrockProviderConfig, buildBedrockProviderSettings, resolveBedrockAuthentication } from "./bedrock-config"
describe("resolveBedrockAuthentication", () => {
it("maps the webview 'apikey' radio value straight through", () => {
expect(resolveBedrockAuthentication({ awsAuthentication: "apikey" })).toBe("apikey")
})
it("maps the webview 'credentials' radio value to the SDK 'iam' spelling", () => {
expect(resolveBedrockAuthentication({ awsAuthentication: "credentials" })).toBe("iam")
})
it("passes 'profile' through", () => {
expect(resolveBedrockAuthentication({ awsAuthentication: "profile" })).toBe("profile")
})
it("defaults to 'profile' when an AWS profile is configured but no auth is set", () => {
expect(resolveBedrockAuthentication({ awsProfile: "dev" })).toBe("profile")
expect(resolveBedrockAuthentication({ awsUseProfile: true })).toBe("profile")
})
it("defaults to 'iam' (default credential chain) when nothing is set", () => {
expect(resolveBedrockAuthentication({})).toBe("iam")
})
})
describe("buildBedrockProviderConfig", () => {
it("forwards the region and api-key authentication for a pasted Bedrock API key", () => {
// Reproduces the reported bug: API key radio + pasted key. The key itself
// is carried separately as the top-level ProviderConfig.apiKey; here we
// assert the region + auth mode that were previously dropped.
const config: ApiConfiguration = {
awsAuthentication: "apikey",
awsRegion: "us-east-1",
awsBedrockApiKey: "bedrock-bearer-token",
}
const result = buildBedrockProviderConfig(config, "act")
expect(result.region).toBe("us-east-1")
expect(result.aws?.authentication).toBe("apikey")
// No SigV4 credentials when authenticating with a bearer API key.
expect(result.aws?.accessKey).toBeUndefined()
expect(result.aws?.secretKey).toBeUndefined()
expect(result.aws?.profile).toBeUndefined()
})
it("forwards static IAM credentials", () => {
const config: ApiConfiguration = {
awsAuthentication: "credentials",
awsRegion: "us-west-2",
awsAccessKey: "AKIA...",
awsSecretKey: "secret",
awsSessionToken: "token",
}
const result = buildBedrockProviderConfig(config, "act")
expect(result.region).toBe("us-west-2")
expect(result.aws?.authentication).toBe("iam")
expect(result.aws?.accessKey).toBe("AKIA...")
expect(result.aws?.secretKey).toBe("secret")
expect(result.aws?.sessionToken).toBe("token")
})
it("forwards the profile only when authenticating via profile", () => {
const profileResult = buildBedrockProviderConfig(
{ awsAuthentication: "profile", awsProfile: "dev-profile", awsRegion: "us-east-2" },
"act",
)
expect(profileResult.aws?.authentication).toBe("profile")
expect(profileResult.aws?.profile).toBe("dev-profile")
// A stale profile string must not leak through when auth is api-key.
const apiKeyResult = buildBedrockProviderConfig(
{ awsAuthentication: "apikey", awsProfile: "dev-profile", awsRegion: "us-east-2" },
"act",
)
expect(apiKeyResult.aws?.authentication).toBe("apikey")
expect(apiKeyResult.aws?.profile).toBeUndefined()
})
it("selects the mode-specific custom model base id", () => {
const config: ApiConfiguration = {
awsAuthentication: "apikey",
awsRegion: "us-east-1",
planModeAwsBedrockCustomModelBaseId: "plan-base",
actModeAwsBedrockCustomModelBaseId: "act-base",
}
expect(buildBedrockProviderConfig(config, "plan").aws?.customModelBaseId).toBe("plan-base")
expect(buildBedrockProviderConfig(config, "act").aws?.customModelBaseId).toBe("act-base")
})
it("forwards cross-region and global inference flags", () => {
const result = buildBedrockProviderConfig(
{
awsAuthentication: "apikey",
awsRegion: "us-east-1",
awsUseCrossRegionInference: true,
awsUseGlobalInference: true,
},
"act",
)
expect(result.useCrossRegionInference).toBe(true)
expect(result.useGlobalInference).toBe(true)
})
it("trims whitespace-only region to undefined", () => {
const result = buildBedrockProviderConfig({ awsAuthentication: "apikey", awsRegion: " " }, "act")
expect(result.region).toBeUndefined()
})
})
describe("buildBedrockProviderSettings (providers.json persistence)", () => {
it("produces SDK ProviderSettings authoritative for the gateway (region + apikey)", () => {
// This is the fix for the second bug: core builds the gateway config from
// the providers.json `stored` entry, so the region + auth must be written
// there (top-level region AND aws.region) to override a stale entry.
const settings = buildBedrockProviderSettings(
{
awsAuthentication: "apikey",
awsRegion: "us-east-2",
awsBedrockApiKey: "bedrock-bearer-token",
},
"us.anthropic.claude-haiku-4-5-20251001-v1:0",
"act",
)
expect(settings.provider).toBe("bedrock")
expect(settings.model).toBe("us.anthropic.claude-haiku-4-5-20251001-v1:0")
expect(settings.apiKey).toBe("bedrock-bearer-token")
// Region must be present BOTH top-level and on aws (toProviderConfig reads
// `settings.region ?? settings.aws?.region`).
expect(settings.region).toBe("us-east-2")
expect(settings.aws?.region).toBe("us-east-2")
expect(settings.aws?.authentication).toBe("apikey")
// No stale SigV4 credentials for api-key auth.
expect(settings.aws?.accessKey).toBeUndefined()
expect(settings.aws?.secretKey).toBeUndefined()
})
it("omits apiKey/region when not configured", () => {
const settings = buildBedrockProviderSettings({ awsAuthentication: "profile", awsProfile: "dev" }, "model-x", "act")
expect(settings.apiKey).toBeUndefined()
expect(settings.region).toBeUndefined()
expect(settings.aws?.authentication).toBe("profile")
expect(settings.aws?.profile).toBe("dev")
})
it("does NOT persist a bearer apiKey for profile auth (keeps stored clean)", () => {
// A stale awsBedrockApiKey must not leak into providers.json when the user
// switched to profile auth — the SDK ignores it for SigV4, but persisting
// it is confusing/leaky.
const settings = buildBedrockProviderSettings(
{
awsAuthentication: "profile",
awsProfile: "default",
awsRegion: "us-east-1",
awsBedrockApiKey: "stale-bearer-token",
},
"us.anthropic.claude-haiku-4-5-20251001-v1:0",
"act",
)
expect(settings.apiKey).toBeUndefined()
expect(settings.aws?.authentication).toBe("profile")
expect(settings.aws?.profile).toBe("default")
expect(settings.aws?.region).toBe("us-east-1")
})
it("does NOT persist a bearer apiKey for iam/credentials auth", () => {
const settings = buildBedrockProviderSettings(
{ awsAuthentication: "credentials", awsRegion: "us-east-1", awsBedrockApiKey: "stale-bearer-token" },
"model-x",
"act",
)
expect(settings.apiKey).toBeUndefined()
expect(settings.aws?.authentication).toBe("iam")
})
})
-128
View File
@@ -1,128 +0,0 @@
// Maps the extension's legacy Bedrock ApiConfiguration onto the SDK's
// structured AWS provider options (region + aws block).
//
// Both inference paths need this:
// - buildSdkProviderConfig() in sdk-api-handler.ts (standalone utility calls)
// - buildSessionConfig() in cline-session-factory.ts (main task loop, which
// hands a CoreSessionConfig.providerConfig to ClineCore)
//
// Without it, the SDK gateway never receives the AWS region or authentication
// mode, so a pasted Bedrock API key (awsBedrockApiKey + awsAuthentication
// "apikey") is silently ignored and requests fall through to the SigV4
// credential chain with no region. This mirrors the structured aws block built
// by the shared provider-settings legacy migration and the CLI.
import type { ProviderSettings } from "@cline/core"
import type { ProviderConfig } from "@cline/llms"
import type { ApiConfiguration } from "@shared/api"
import type { Mode } from "@shared/storage/types"
type AwsConfig = NonNullable<ProviderConfig["aws"]>
type AwsAuthentication = NonNullable<AwsConfig["authentication"]>
/** The Bedrock-specific subset of an SDK ProviderConfig. */
export type BedrockProviderConfig = Pick<ProviderConfig, "region" | "aws" | "useCrossRegionInference" | "useGlobalInference">
function trimToUndefined(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined
}
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : undefined
}
/**
* Map the webview's `awsAuthentication` radio value onto the SDK's
* `AwsConfig.authentication` spelling.
*
* The Bedrock settings UI stores `"apikey"`, `"profile"`, or `"credentials"`.
* The SDK understands `"apikey"`/`"api-key"`, `"profile"`, and `"iam"`. The
* webview's `"credentials"` option means "use the default AWS credential
* chain", which is `"iam"` in SDK terms. When unset, fall back to the same
* heuristic the UI uses for its default radio selection (profile when an AWS
* profile is configured, otherwise the default credential chain).
*/
export function resolveBedrockAuthentication(configuration: ApiConfiguration): AwsAuthentication {
const explicit = configuration.awsAuthentication
if (explicit === "apikey" || explicit === "api-key" || explicit === "profile" || explicit === "iam") {
return explicit
}
if (explicit === "credentials") {
return "iam"
}
// No explicit selection: mirror the webview's default radio resolution.
if (configuration.awsUseProfile || trimToUndefined(configuration.awsProfile)) {
return "profile"
}
return "iam"
}
/**
* Build the Bedrock `region` + `aws` portion of the SDK ProviderConfig from the
* extension's ApiConfiguration for the given mode (plan/act).
*/
export function buildBedrockProviderConfig(configuration: ApiConfiguration, mode: Mode): BedrockProviderConfig {
const authentication = resolveBedrockAuthentication(configuration)
const usesProfile = authentication === "profile"
const aws: AwsConfig = {
accessKey: trimToUndefined(configuration.awsAccessKey),
secretKey: trimToUndefined(configuration.awsSecretKey),
sessionToken: trimToUndefined(configuration.awsSessionToken),
authentication,
profile: usesProfile ? trimToUndefined(configuration.awsProfile) : undefined,
usePromptCache: configuration.awsBedrockUsePromptCache,
endpoint: trimToUndefined(configuration.awsBedrockEndpoint),
customModelBaseId: trimToUndefined(
mode === "plan"
? configuration.planModeAwsBedrockCustomModelBaseId
: configuration.actModeAwsBedrockCustomModelBaseId,
),
}
return {
region: trimToUndefined(configuration.awsRegion),
aws,
useCrossRegionInference: configuration.awsUseCrossRegionInference,
useGlobalInference: configuration.awsUseGlobalInference,
}
}
/**
* Build the full SDK `ProviderSettings` for Bedrock from the extension's
* ApiConfiguration, suitable for persisting to providers.json via
* `ProviderSettingsManager.saveProviderSettings`.
*
* WHY THIS EXISTS (the second Bedrock bug):
* The main chat path runs through core's `buildProviderConfig`
* (local-runtime-bootstrap.ts), which builds the gateway-registered
* ProviderSettings as `{ ...stored, provider, model, apiKey, baseUrl, ... }`.
* The `aws` block and `region` come ONLY from `stored` (providers.json) the
* session's `providerConfig` is consulted by a different code path and does NOT
* override the gateway registration. So a stale providers.json Bedrock entry
* (e.g. a legacy migration with region "us-east-1" + SigV4 keys) silently wins:
* requests go to the wrong region and 403, even though StateManager has the
* correct region + apikey auth.
*
* Writing the StateManager-derived settings back to providers.json makes
* `stored` authoritative and correct, so the gateway is configured with the
* region/auth the user actually selected.
*/
export function buildBedrockProviderSettings(configuration: ApiConfiguration, modelId: string, mode: Mode): ProviderSettings {
const { region, aws, useCrossRegionInference, useGlobalInference } = buildBedrockProviderConfig(configuration, mode)
// Only persist the bearer apiKey when actually authenticating with api-key.
// Otherwise (profile/iam) a stale key could linger in providers.json; the SDK
// ignores it for SigV4 auth, but we keep `stored` clean and unambiguous.
const usesApiKeyAuth = aws?.authentication === "apikey" || aws?.authentication === "api-key"
const apiKey = usesApiKeyAuth ? trimToUndefined(configuration.awsBedrockApiKey) : undefined
return {
provider: "bedrock",
model: modelId,
...(apiKey ? { apiKey } : {}),
...(region ? { region } : {}),
aws: {
...aws,
...(region ? { region } : {}),
...(useCrossRegionInference !== undefined ? { useCrossRegionInference } : {}),
...(useGlobalInference !== undefined ? { useGlobalInference } : {}),
},
}
}
File diff suppressed because it is too large Load Diff
+342 -140
View File
@@ -2,7 +2,7 @@
//
// Creates and manages SDK sessions using ClineCore. This factory handles:
// - Creating ClineCore instances with proper configuration
// - Building session config from legacy state (provider, model, API key)
// - Building session config from SDK provider settings plus VS Code host state
// - Custom session persistence adapter reading ~/.cline/data/tasks/
// - Mapping HistoryItem ↔ SDK session fields
//
@@ -21,22 +21,26 @@ import { buildClineSystemPrompt } from "@cline/shared"
import type { ApiConfiguration } from "@shared/api"
import type { HistoryItem } from "@shared/HistoryItem"
import { DEFAULT_LANGUAGE_SETTINGS, getLanguageKey, type LanguageDisplay } from "@shared/Languages"
import {
isVscodeUnsupportedProvider,
toVscodeSupportedProvider,
VSCODE_DEFAULT_PROVIDER_ID,
} from "@shared/model-catalog/provider-helpers"
import { Logger } from "@shared/services/Logger"
import type { Settings } from "@shared/storage/state-keys"
import type { RemoteProviderModelSettings, Settings } from "@shared/storage/state-keys"
import type { Mode } from "@shared/storage/types"
import { stringifyVsCodeLmModelSelector } from "@shared/vsCodeSelectorUtils"
import { mirrorPlanActApiConfiguration } from "@/core/controller/models/sharedModeConfiguration"
import { StateManager } from "@/core/storage/StateManager"
import { ExtensionRegistryInfo } from "@/registry"
import { getFeatureFlagsService } from "@/services/feature-flags"
import { getDistinctId } from "@/services/logging/distinctId"
import { fetch } from "@/shared/net"
import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
import { type BedrockProviderConfig, buildBedrockProviderConfig } from "./bedrock-config"
import { buildAgentHooks } from "./hooks-adapter"
import { readTaskHistory, resolveDataDir } from "./legacy-state-reader"
import { buildEffectiveProviderConfig, buildRemoteProviderConfig } from "./model-catalog/effective-config"
import { parseProviderId } from "./model-catalog/provider-id"
import { toSdkProviderId } from "./model-catalog/sdk-provider-id"
import { getProviderSettingsManager } from "./provider-migration"
import { buildSapProviderConfig, type SapProviderConfig } from "./sap-config"
import type { SdkSessionHost } from "./session-host"
// ---------------------------------------------------------------------------
@@ -124,6 +128,7 @@ function resolveWorkspaceName(workspacePath: string): string {
type ReasoningEffort = NonNullable<CoreSessionConfig["reasoningEffort"]>
type ProviderReasoningSettings = NonNullable<ProviderSettings["reasoning"]>
type SessionReasoningConfig = Pick<CoreSessionConfig, "thinking" | "reasoningEffort">
type RuntimeProviderConfig = NonNullable<CoreSessionConfig["providerConfig"]>
function isReasoningEffort(value: unknown): value is ReasoningEffort {
return value === "low" || value === "medium" || value === "high" || value === "xhigh"
@@ -133,6 +138,24 @@ function hasStaleDisabledReasoningFields(reasoning: ProviderReasoningSettings |
return reasoning?.enabled === false && (reasoning.effort !== undefined || reasoning.budgetTokens !== undefined)
}
function resolvePersistedProviderConfig(
providerId: string,
dataDir: string = resolveDataDir(),
): RuntimeProviderConfig | undefined {
const manager = getProviderSettingsManager(dataDir)
const sdkProviderId = toSdkProviderId(providerId)
return (
manager.getProviderConfig(sdkProviderId, { includeKnownModels: false }) ??
manager.getProviderConfig(providerId, { includeKnownModels: false })
)
}
function resolvePersistedProviderSettings(providerId: string, dataDir: string = resolveDataDir()): ProviderSettings | undefined {
const manager = getProviderSettingsManager(dataDir)
const sdkProviderId = toSdkProviderId(providerId)
return manager.getProviderSettings(sdkProviderId) ?? manager.getProviderSettings(providerId)
}
/**
* Convert SDK provider-level reasoning settings into the SDK session fields that
* are actually forwarded as model options. Keep `thinking` and
@@ -157,12 +180,42 @@ export function normalizeProviderReasoningSettings(reasoning: ProviderReasoningS
return isReasoningEffort(reasoning.effort) ? { reasoningEffort: reasoning.effort } : {}
}
function resolveProviderReasoningConfig(providerId: string): SessionReasoningConfig {
function hasSessionReasoningConfig(config: SessionReasoningConfig): boolean {
return config.thinking !== undefined || config.reasoningEffort !== undefined
}
function normalizeLegacyReasoningEffort(value: unknown): SessionReasoningConfig {
if (value === "none") {
return { thinking: false }
}
if (isReasoningEffort(value)) {
return { thinking: true, reasoningEffort: value }
}
return {}
}
function resolveLegacyReasoningConfig(providerId: string, mode: Mode, apiConfig: ApiConfiguration): SessionReasoningConfig {
const sdkProviderId = toSdkProviderId(providerId)
if (sdkProviderId === "openai-codex") {
return normalizeLegacyReasoningEffort(
mode === "plan" ? apiConfig.planModeReasoningEffort : apiConfig.actModeReasoningEffort,
)
}
if (sdkProviderId === "oca") {
return normalizeLegacyReasoningEffort(
mode === "plan" ? apiConfig.planModeOcaReasoningEffort : apiConfig.actModeOcaReasoningEffort,
)
}
return {}
}
function resolveProviderReasoningConfig(providerId: string, mode: Mode, apiConfig: ApiConfiguration): SessionReasoningConfig {
try {
const manager = getProviderSettingsManager(resolveDataDir())
const settings = manager.getProviderSettings(providerId)
const sdkProviderId = toSdkProviderId(providerId)
const settings = manager.getProviderSettings(sdkProviderId) ?? manager.getProviderSettings(providerId)
if (!settings) {
return {}
return resolveLegacyReasoningConfig(providerId, mode, apiConfig)
}
if (hasStaleDisabledReasoningFields(settings.reasoning)) {
@@ -175,33 +228,16 @@ function resolveProviderReasoningConfig(providerId: string): SessionReasoningCon
return normalizeProviderReasoningSettings(sanitizedSettings.reasoning)
}
return normalizeProviderReasoningSettings(settings.reasoning)
const providerReasoningConfig = normalizeProviderReasoningSettings(settings.reasoning)
return hasSessionReasoningConfig(providerReasoningConfig)
? providerReasoningConfig
: resolveLegacyReasoningConfig(providerId, mode, apiConfig)
} catch (error) {
Logger.warn("[SessionFactory] Provider reasoning resolution failed:", error)
return {}
return resolveLegacyReasoningConfig(providerId, mode, apiConfig)
}
}
function resolveOcaReasoningConfig(mode: Mode, apiConfig: ApiConfiguration | undefined): SessionReasoningConfig | undefined {
const rawEffort = mode === "plan" ? apiConfig?.planModeOcaReasoningEffort : apiConfig?.actModeOcaReasoningEffort
const effort = rawEffort?.trim().toLowerCase()
if (!effort) {
return undefined
}
if (effort === "none") {
return { thinking: false }
}
return isReasoningEffort(effort) ? { thinking: true, reasoningEffort: effort } : undefined
}
function resolveOpenAiCompatibleMaxTokens(config: ApiConfiguration | undefined, mode: Mode): number | undefined {
const modelInfo = mode === "plan" ? config?.planModeOpenAiModelInfo : config?.actModeOpenAiModelInfo
const maxTokens = modelInfo?.maxTokens
return typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0 ? maxTokens : undefined
}
// ---------------------------------------------------------------------------
// Provider → API key field mapping
// ---------------------------------------------------------------------------
@@ -247,6 +283,7 @@ const PROVIDER_API_KEY_MAP: Record<string, keyof ApiConfiguration> = {
aihubmix: "aihubmixApiKey",
nousResearch: "nousResearchApiKey",
"vercel-ai-gateway": "vercelAiGatewayApiKey",
sapaicore: "sapAiCoreClientId", // SAP uses client ID + secret
claude_code: "apiKey", // Claude Code uses anthropic key
wandb: "wandbApiKey",
"qwen-code": "qwenApiKey",
@@ -284,13 +321,14 @@ const PROVIDER_MODEL_ID_MAP: Record<string, { plan: keyof ApiConfiguration; act:
hicap: { plan: "planModeHicapModelId", act: "actModeHicapModelId" },
nousResearch: { plan: "planModeNousResearchModelId", act: "actModeNousResearchModelId" },
"vercel-ai-gateway": { plan: "planModeVercelAiGatewayModelId", act: "actModeVercelAiGatewayModelId" },
sapaicore: { plan: "planModeSapAiCoreModelId", act: "actModeSapAiCoreModelId" },
}
// ---------------------------------------------------------------------------
// Provider/model defaults
// ---------------------------------------------------------------------------
const DEFAULT_PROVIDER_ID = "cline"
const DEFAULT_PROVIDER_ID = VSCODE_DEFAULT_PROVIDER_ID
export function getDefaultModelIdForProvider(providerId: string): string | undefined {
const sdkProviderId = toSdkProviderId(providerId)
@@ -353,20 +391,6 @@ export function resolveApiKey(providerId: string, config: ApiConfiguration): str
}
}
// SDK-backed API-key providers save credentials in providers.json instead
// of legacy ApiConfiguration fields. Fall back to that store so providers
// exposed through the SDK settings UI still receive credentials at task
// startup.
try {
const manager = getProviderSettingsManager()
const apiKey = resolveProviderApiKeyFromSettings(manager, providerId)?.trim()
if (apiKey) {
return apiKey
}
} catch {
Logger.warn(`[SessionFactory] Failed to read ${providerId} API key from providers.json`)
}
return undefined
}
@@ -374,7 +398,7 @@ export function resolveApiKey(providerId: string, config: ApiConfiguration): str
* Resolve the model ID for a given provider and mode from the ApiConfiguration.
* Uses mode-specific model ID fields when available, falls back to generic fields.
*/
export function resolveModelId(providerId: string, mode: Mode, config: ApiConfiguration): string | undefined {
function resolveModelId(providerId: string, mode: Mode, config: ApiConfiguration): string | undefined {
// VS Code LM has no plain model-id field: the selected model is stored as a
// structured LanguageModelChatSelector ({vendor, family, ...}) in
// plan/actModeVsCodeLmModelSelector. The SDK ProviderConfig only carries a
@@ -385,16 +409,6 @@ export function resolveModelId(providerId: string, mode: Mode, config: ApiConfig
return selector ? stringifyVsCodeLmModelSelector(selector) || undefined : undefined
}
if (providerId === "sapaicore") {
const genericField = mode === "plan" ? "planModeApiModelId" : "actModeApiModelId"
const legacyField = mode === "plan" ? "planModeSapAiCoreModelId" : "actModeSapAiCoreModelId"
return (
(config[genericField] as string | undefined)?.trim() ||
(config[legacyField] as string | undefined)?.trim() ||
undefined
)
}
// Check provider-specific mode model ID fields.
// If the provider has a dedicated field, do not fall back to generic
// *ModeApiModelId. Those generic slots may contain a stale model from a
@@ -447,28 +461,7 @@ export function normalizeSdkBaseUrl(providerId: string, baseUrl: unknown): strin
return trimmed
}
export function resolveVertexProviderConfig(config: ApiConfiguration): Pick<ProviderSettings, "gcp" | "region"> {
let providerSettingsProjectId: string | undefined
let providerSettingsRegion: string | undefined
try {
const settings = getProviderSettingsManager().getProviderSettings("vertex")
providerSettingsProjectId = settings?.gcp?.projectId?.trim() || undefined
providerSettingsRegion = settings?.gcp?.region?.trim() || settings?.region?.trim() || undefined
} catch {
Logger.warn("[SessionFactory] Failed to read Vertex settings from providers.json")
}
const region = (providerSettingsRegion ?? config.vertexRegion?.trim()) || undefined
return {
region,
gcp: {
projectId: (providerSettingsProjectId ?? config.vertexProjectId?.trim()) || undefined,
region,
},
}
}
export function resolveBaseUrl(providerId: string, config: ApiConfiguration): string | undefined {
function resolveBaseUrl(providerId: string, config: ApiConfiguration): string | undefined {
const baseUrlMap: Record<string, keyof ApiConfiguration> = {
anthropic: "anthropicBaseUrl",
openai: "openAiBaseUrl",
@@ -490,6 +483,241 @@ export function resolveBaseUrl(providerId: string, config: ApiConfiguration): st
return undefined
}
function mergeRuntimeObject<T extends object>(first: T | undefined, second: T | undefined): T | undefined {
if (!first) {
return second
}
if (!second) {
return first
}
const merged: Record<string, unknown> = { ...(first as Record<string, unknown>) }
for (const [key, value] of Object.entries(second)) {
if (value !== undefined) {
merged[key] = value
}
}
return merged as T
}
function isSdkAwsAuthentication(value: unknown): value is NonNullable<RuntimeProviderConfig["aws"]>["authentication"] {
return value === "iam" || value === "api-key" || value === "apikey" || value === "profile"
}
function normalizeSdkAwsAuthentication(value: unknown): NonNullable<RuntimeProviderConfig["aws"]>["authentication"] | undefined {
if (value === "credentials") {
return "iam"
}
return isSdkAwsAuthentication(value) ? value : undefined
}
function toRuntimeAwsConfig(aws: ReturnType<typeof buildEffectiveProviderConfig>["aws"]): RuntimeProviderConfig["aws"] {
if (!aws) {
return undefined
}
return {
...aws,
authentication: normalizeSdkAwsAuthentication(aws.authentication),
}
}
function isBedrockApiKeyAuthentication(authentication: unknown): boolean {
return authentication === "api-key" || authentication === "apikey"
}
function isSdkSapApi(value: unknown): value is NonNullable<RuntimeProviderConfig["sap"]>["api"] {
return value === "orchestration" || value === "foundation-models"
}
function isSdkOcaMode(value: unknown): value is NonNullable<RuntimeProviderConfig["oca"]>["mode"] {
return value === "internal" || value === "external"
}
function toRuntimeApiLine(value: unknown): RuntimeProviderConfig["apiLine"] {
return value === "china" || value === "international" ? value : undefined
}
function toRuntimeSapConfig(sap: ReturnType<typeof buildEffectiveProviderConfig>["sap"]): RuntimeProviderConfig["sap"] {
if (!sap) {
return undefined
}
return {
...sap,
api: isSdkSapApi(sap.api) ? sap.api : undefined,
defaultSettings: sap.defaultSettings ? { ...sap.defaultSettings } : undefined,
}
}
function toRuntimeOcaConfig(oca: ReturnType<typeof buildEffectiveProviderConfig>["oca"]): RuntimeProviderConfig["oca"] {
if (!oca) {
return undefined
}
return {
...oca,
mode: isSdkOcaMode(oca.mode) ? oca.mode : undefined,
}
}
function readModeSpecificSapDeploymentId(providerId: string, mode: Mode, config: ApiConfiguration): string | undefined {
if (providerId !== "sapaicore") {
return undefined
}
const value = mode === "plan" ? config.planModeSapAiCoreDeploymentId : config.actModeSapAiCoreDeploymentId
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined
}
function readModeSpecificBedrockCustomModelBaseId(providerId: string, mode: Mode, config: ApiConfiguration): string | undefined {
if (providerId !== "bedrock") {
return undefined
}
const value = mode === "plan" ? config.planModeAwsBedrockCustomModelBaseId : config.actModeAwsBedrockCustomModelBaseId
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined
}
function readRemoteBedrockCustomModelBaseId(modelId: string | undefined): string | undefined {
if (!modelId) {
return undefined
}
return readRemoteProviderModelSettings("bedrock")?.bedrockCustomModels?.find((model) => model.name === modelId)?.baseModelId
}
function readRemoteProviderModelSettings(providerId: string): RemoteProviderModelSettings[string] | undefined {
try {
const manager = StateManager.get() as { getRemoteConfigSettings?: () => unknown }
const settings = manager.getRemoteConfigSettings?.()
if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
return undefined
}
const remoteProviderModelSettings = (settings as { remoteProviderModelSettings?: RemoteProviderModelSettings })
.remoteProviderModelSettings
const sdkProviderId = toSdkProviderId(providerId)
return remoteProviderModelSettings?.[sdkProviderId] ?? remoteProviderModelSettings?.[providerId]
} catch {
return undefined
}
}
function resolveRemoteAllowedModelId(providerId: string, modelId: string | undefined): string | undefined {
const remoteModelSettings = readRemoteProviderModelSettings(providerId)
if (!remoteModelSettings?.models?.length && !remoteModelSettings?.bedrockCustomModels?.length) {
return modelId
}
const allowedModelIds = [
...(remoteModelSettings.models ?? []).map((model) => model.id),
...(remoteModelSettings.bedrockCustomModels ?? []).map((model) => model.name),
].filter((value) => value.trim().length > 0)
if (allowedModelIds.length === 0) {
return modelId
}
return modelId && allowedModelIds.includes(modelId) ? modelId : allowedModelIds[0]
}
/**
* Runtime provider config is built from the same effective SDK/legacy boundary
* as the catalog. A partial providers.json entry is not authoritative; missing
* fields are filled from legacy StateManager overlays so old installs keep
* working while the SDK-owned provider shape becomes the runtime contract.
*/
export function buildRuntimeProviderConfig(providerId: string, mode: Mode, apiConfig: ApiConfiguration): RuntimeProviderConfig {
const sharedApiConfig = mirrorPlanActApiConfiguration(apiConfig)
const runtimeProviderId = toVscodeSupportedProvider(providerId)
const persistedProviderConfig = resolvePersistedProviderConfig(runtimeProviderId)
const persistedProviderSettings = resolvePersistedProviderSettings(runtimeProviderId)
const parsedProviderId = parseProviderId(runtimeProviderId)
const effectiveConfig = buildEffectiveProviderConfig(parsedProviderId)
const remoteConfig = buildRemoteProviderConfig(parsedProviderId)
const rawModelId =
persistedProviderConfig?.modelId ??
resolveModelId(runtimeProviderId, mode, sharedApiConfig) ??
getDefaultModelIdForProvider(runtimeProviderId)
const modelId = resolveRemoteAllowedModelId(runtimeProviderId, rawModelId)
const apiKey =
remoteConfig.apiKey ??
persistedProviderConfig?.apiKey ??
effectiveConfig.apiKey ??
resolveApiKey(runtimeProviderId, apiConfig)
const persistedExplicitBaseUrl =
typeof persistedProviderSettings?.baseUrl === "string" && persistedProviderSettings.baseUrl.trim().length > 0
? persistedProviderConfig?.baseUrl
: undefined
const baseUrl =
remoteConfig.baseUrl ??
(parsedProviderId === "oca"
? (effectiveConfig.baseUrl ??
resolveBaseUrl(runtimeProviderId, sharedApiConfig) ??
persistedExplicitBaseUrl ??
persistedProviderConfig?.baseUrl)
: (persistedExplicitBaseUrl ??
effectiveConfig.baseUrl ??
resolveBaseUrl(runtimeProviderId, sharedApiConfig) ??
persistedProviderConfig?.baseUrl))
const bedrockCustomModelBaseId =
readRemoteBedrockCustomModelBaseId(modelId) ??
readModeSpecificBedrockCustomModelBaseId(runtimeProviderId, mode, sharedApiConfig)
const mergedAwsBase = mergeRuntimeObject(
mergeRuntimeObject(effectiveConfig.aws, persistedProviderConfig?.aws),
remoteConfig.aws,
)
const mergedAws = bedrockCustomModelBaseId
? { ...(mergedAwsBase ?? {}), customModelBaseId: bedrockCustomModelBaseId }
: mergedAwsBase
const aws = toRuntimeAwsConfig(mergedAws)
const gcp = mergeRuntimeObject(mergeRuntimeObject(effectiveConfig.gcp, persistedProviderConfig?.gcp), remoteConfig.gcp)
const azure = mergeRuntimeObject(
mergeRuntimeObject(effectiveConfig.azure, persistedProviderConfig?.azure),
remoteConfig.azure,
)
const apiLine = toRuntimeApiLine(remoteConfig.apiLine ?? persistedProviderConfig?.apiLine ?? effectiveConfig.apiLine)
const modeSpecificSapDeploymentId = readModeSpecificSapDeploymentId(runtimeProviderId, mode, sharedApiConfig)
const mergedSapBase = mergeRuntimeObject(
mergeRuntimeObject(effectiveConfig.sap, persistedProviderConfig?.sap),
remoteConfig.sap,
)
const mergedSap = modeSpecificSapDeploymentId
? { ...(mergedSapBase ?? {}), deploymentId: modeSpecificSapDeploymentId }
: mergedSapBase
const sap = toRuntimeSapConfig(mergedSap)
const oca = toRuntimeOcaConfig(
mergeRuntimeObject(mergeRuntimeObject(persistedProviderSettings?.oca, effectiveConfig.oca), remoteConfig.oca),
)
const region =
gcp?.region ?? mergedAws?.region ?? remoteConfig.region ?? effectiveConfig.region ?? persistedProviderConfig?.region
const runtimeApiKey =
runtimeProviderId === "bedrock" && !isBedrockApiKeyAuthentication(aws?.authentication) ? undefined : apiKey
const {
apiKey: _persistedApiKey,
thinking: _persistedThinking,
reasoningEffort: _persistedReasoningEffort,
thinkingBudgetTokens: _persistedThinkingBudgetTokens,
...persistedProviderConfigWithoutRuntimeOnlyFields
} = persistedProviderConfig ?? {}
return {
...persistedProviderConfigWithoutRuntimeOnlyFields,
providerId: toSdkProviderId(runtimeProviderId),
modelId: modelId || getDefaultModelIdForProvider(runtimeProviderId) || "",
apiLine,
...(runtimeApiKey ? { apiKey: runtimeApiKey } : {}),
...(baseUrl ? { baseUrl } : {}),
...((remoteConfig.headers ?? effectiveConfig.headers)
? { headers: { ...(remoteConfig.headers ?? effectiveConfig.headers) } }
: {}),
...(effectiveConfig.auth?.accessToken ? { accessToken: effectiveConfig.auth.accessToken } : {}),
...(effectiveConfig.auth?.refreshToken ? { refreshToken: effectiveConfig.auth.refreshToken } : {}),
...(effectiveConfig.auth?.accountId ? { accountId: effectiveConfig.auth.accountId } : {}),
...(region ? { region } : {}),
...(aws ? { aws } : {}),
...(gcp ? { gcp } : {}),
...(azure ? { azure } : {}),
...(sap ? { sap } : {}),
...(oca ? { oca } : {}),
...(mergedAws?.useCrossRegionInference !== undefined
? { useCrossRegionInference: mergedAws.useCrossRegionInference }
: {}),
...(mergedAws?.useGlobalInference !== undefined ? { useGlobalInference: mergedAws.useGlobalInference } : {}),
}
}
// ---------------------------------------------------------------------------
// Session config builder
// ---------------------------------------------------------------------------
@@ -498,8 +726,9 @@ export function resolveBaseUrl(providerId: string, config: ApiConfiguration): st
* Build a CoreSessionConfig from the current state.
*
* Reads provider settings from the classic StateManager's ApiConfiguration
* (which correctly reads from globalState.json + secrets.json), then resolves
* the provider, model, and API key for the current mode (plan/act).
* (which correctly reads from globalState.json + secrets.json), mirrors legacy
* plan/act slots to one shared selection, then resolves the provider, model,
* and API key for the current mode's runtime behavior.
*
* This replaces the previous two-path approach (SDK ProviderSettingsManager +
* StateManager.buildApiHandlerSettings) which both failed silently.
@@ -519,53 +748,33 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
let apiKey: string | undefined
let baseUrl: string | undefined
let apiConfig: ApiConfiguration | undefined
// Cloud-provider structured options. The core runtime reads these from
// CoreSessionConfig.providerConfig; without them the SDK gateway never receives
// region/project/auth fields for inference calls.
let bedrockProviderConfig: BedrockProviderConfig | undefined
let vertexProviderConfig: Pick<ProviderSettings, "gcp" | "region"> | undefined
let sapProviderConfig: SapProviderConfig | undefined
let sdkProviderConfig: CoreSessionConfig["providerConfig"] | undefined
try {
const stateManager = StateManager.get()
apiConfig = stateManager.getApiConfiguration()
apiConfig = mirrorPlanActApiConfiguration(stateManager.getApiConfiguration())
// Resolve the provider for the current mode
// Resolve the shared provider selection. The mode still controls tool
// access and prompting, but not provider/model choice.
const modeProvider = mode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider
providerId = modeProvider
providerId = modeProvider ? toVscodeSupportedProvider(modeProvider, DEFAULT_PROVIDER_ID) : undefined
if (modeProvider && providerId !== modeProvider) {
Logger.warn(`[SessionFactory] Provider ${modeProvider} is unsupported in VS Code; using ${providerId} for runtime`)
}
if (providerId) {
// Resolve API key
apiKey = resolveApiKey(providerId, apiConfig)
// Resolve model ID
modelId = resolveModelId(providerId, mode, apiConfig)
// Resolve base URL
baseUrl = resolveBaseUrl(providerId, apiConfig)
// Resolve Bedrock region + AWS authentication options from the legacy
// ApiConfiguration (StateManager is the VSCode source of truth, not
// providers.json).
if (providerId === "bedrock") {
bedrockProviderConfig = buildBedrockProviderConfig(apiConfig, mode)
}
if (providerId === "vertex") {
vertexProviderConfig = resolveVertexProviderConfig(apiConfig)
}
if (providerId === "sapaicore") {
sapProviderConfig = buildSapProviderConfig(apiConfig, mode)
baseUrl = sapProviderConfig.baseUrl
}
const sdkProviderId = toSdkProviderId(providerId)
sdkProviderConfig = buildRuntimeProviderConfig(providerId, mode, apiConfig)
modelId = sdkProviderConfig.modelId
apiKey = sdkProviderConfig.apiKey
baseUrl = sdkProviderConfig.baseUrl
Logger.log(
`[SessionFactory] Resolved from StateManager: provider=${providerId}, model=${modelId}, hasApiKey=${!!apiKey}`,
`[SessionFactory] Resolved provider config: provider=${providerId}, sdkProvider=${sdkProviderId}, model=${modelId}, source=effective, hasApiKey=${!!apiKey}`,
)
}
} catch (error) {
Logger.warn("[SessionFactory] StateManager credential resolution failed:", error)
Logger.warn("[SessionFactory] Provider config resolution failed:", error)
}
// Fallback: try SDK's ProviderSettingsManager only when StateManager did not
@@ -576,16 +785,20 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
try {
const dataDir = resolveDataDir()
const manager = getProviderSettingsManager(dataDir)
const lastUsed = manager.getLastUsedProviderSettings({
isClinePassEnabled: getFeatureFlagsService().getBooleanFlagEnabled(FeatureFlag.CLINE_PASS),
})
const lastUsed = manager.getLastUsedProviderSettings()
if (lastUsed?.provider && lastUsed?.apiKey) {
if (lastUsed?.provider && !isVscodeUnsupportedProvider(lastUsed.provider)) {
const lastUsedConfig = manager.getProviderConfig(lastUsed.provider, { includeKnownModels: false })
providerId = lastUsed.provider
modelId = lastUsed.model
apiKey = lastUsed.apiKey
baseUrl = lastUsed.baseUrl
sdkProviderConfig = lastUsedConfig
modelId = lastUsedConfig?.modelId ?? lastUsed.model
apiKey = lastUsedConfig?.apiKey ?? lastUsed.apiKey
baseUrl = lastUsedConfig?.baseUrl ?? lastUsed.baseUrl
Logger.log(`[SessionFactory] Using SDK provider fallback: ${providerId}/${modelId}`)
} else if (lastUsed?.provider) {
Logger.warn(
`[SessionFactory] Ignoring unsupported SDK provider fallback ${lastUsed.provider}; using ${DEFAULT_PROVIDER_ID}`,
)
}
} catch (error) {
Logger.warn("[SessionFactory] SDK ProviderSettingsManager fallback failed:", error)
@@ -596,15 +809,13 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
// session factory share one source of truth for default models.
providerId = providerId ?? DEFAULT_PROVIDER_ID
modelId = modelId ?? getDefaultModelIdForProvider(providerId) ?? getDefaultModelIdForProvider(DEFAULT_PROVIDER_ID) ?? ""
if (!apiKey && apiConfig) {
const shouldResolveLegacyApiKey =
providerId !== "bedrock" || isBedrockApiKeyAuthentication(sdkProviderConfig?.aws?.authentication)
if (!apiKey && apiConfig && shouldResolveLegacyApiKey) {
apiKey = resolveApiKey(providerId, apiConfig)
}
apiKey = apiKey ?? ""
const maxTokensPerTurn = providerId === "openai" ? resolveOpenAiCompatibleMaxTokens(apiConfig, mode) : undefined
const reasoningConfig =
providerId === "oca"
? (resolveOcaReasoningConfig(mode, apiConfig) ?? resolveProviderReasoningConfig(providerId))
: resolveProviderReasoningConfig(providerId)
const reasoningConfig = resolveProviderReasoningConfig(providerId, mode, apiConfig ?? {})
// Build the system prompt using the shared prompt builder. Core still
// expects callers to provide a concrete systemPrompt, but the prompt builder
@@ -657,20 +868,12 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
// extension's "openai"). Convert before handing the id to core.
const sdkProviderId = toSdkProviderId(providerId)
// Always pass a providerConfig so the proxy/CA-aware fetch reaches the SDK
// gateway; without it the agent loop uses bare global fetch and corporate
// proxy/self-signed CA setups fail on JetBrains and CLI. Cloud providers
// additionally need structured options (region/project/auth/SAP OAuth), which core
// reads from providerConfig in createAgentModelFromConfig.
const cloudProviderConfig = bedrockProviderConfig ?? vertexProviderConfig ?? sapProviderConfig
// Spread the cloud config first so the explicit fields below — notably the
// proxy/CA-aware fetch — can never be clobbered if those types gain matching keys.
const providerConfig = {
...(cloudProviderConfig ?? {}),
...(sdkProviderConfig ?? {}),
providerId: sdkProviderId,
modelId,
...(apiKey ? { apiKey } : {}),
...(baseUrl !== undefined ? { baseUrl } : {}),
...(baseUrl ? { baseUrl } : {}),
fetch,
}
@@ -697,7 +900,6 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
disableMcpSettingsTools: true,
mode: mode === "plan" ? "plan" : "act",
...reasoningConfig,
...(maxTokensPerTurn !== undefined ? { maxTokensPerTurn } : {}),
maxIterations: undefined,
logger: sdkLogger,
extensionContext: {
+322 -69
View File
@@ -1,5 +1,6 @@
import type { ModelInfo } from "@shared/api"
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { _testing, createProviderCatalog } from "./catalog"
import type {
EffectiveProviderConfig,
Fingerprint,
@@ -11,13 +12,33 @@ import type {
import { computeConfigFingerprint } from "./fingerprint"
import { parseProviderId } from "./provider-id"
const mocks = vi.hoisted(() => ({
resolveProviderConfig: vi.fn(),
listLocalProviders: vi.fn(),
getBooleanFlagEnabled: vi.fn(() => false),
pollFeatureFlags: vi.fn(async (): Promise<void> => undefined),
getProviderSettings: vi.fn((): any => undefined),
}))
const mocks = vi.hoisted(() => {
let apiConfiguration: Record<string, unknown> = {}
let remoteConfigSettings: Record<string, unknown> = {}
let providerSettingsById: Record<string, unknown> = {}
return {
resolveProviderConfig: vi.fn(),
listLocalProviders: vi.fn(),
setApiConfiguration(value: Record<string, unknown>): void {
apiConfiguration = value
},
setProviderSettings(value: Record<string, unknown>): void {
providerSettingsById = value
},
getApiConfiguration(): Record<string, unknown> {
return apiConfiguration
},
setRemoteConfigSettings(value: Record<string, unknown>): void {
remoteConfigSettings = value
},
getRemoteConfigSettings(): Record<string, unknown> {
return remoteConfigSettings
},
getProviderSettings(providerId: string): unknown {
return providerSettingsById[providerId]
},
}
})
vi.mock("@cline/core", async (importOriginal: any) => {
const actual = await importOriginal()
@@ -28,15 +49,13 @@ vi.mock("@cline/core", async (importOriginal: any) => {
}
})
vi.mock("@/services/feature-flags", () => ({
getFeatureFlagsService: () => ({
getBooleanFlagEnabled: mocks.getBooleanFlagEnabled,
poll: mocks.pollFeatureFlags,
}),
}))
vi.mock("@/services/logging/distinctId", () => ({
setDistinctId: vi.fn(),
vi.mock("@/core/storage/StateManager", () => ({
StateManager: {
get: () => ({
getApiConfiguration: mocks.getApiConfiguration,
getRemoteConfigSettings: mocks.getRemoteConfigSettings,
}),
},
}))
vi.mock("../provider-migration", () => ({
@@ -54,24 +73,12 @@ type TestReader = ProviderConfigReader & {
emit(event: ProviderConfigChange): void
}
// Warm the catalog module graph once before any test runs. catalog.ts statically
// pulls in @cline/core, @cline/llms and @cline/shared, so the first test to call
// `await import("./catalog")` otherwise pays the entire (>5s on CI) import cost
// inside its own 5s test timeout and flakily fails. Importing here moves that cost
// outside any per-test clock (hooks get a generous timeout of their own).
beforeAll(async () => {
await import("./catalog")
}, 60_000)
beforeEach(() => {
mocks.resolveProviderConfig.mockReset()
mocks.listLocalProviders.mockReset()
mocks.getBooleanFlagEnabled.mockReset()
mocks.getBooleanFlagEnabled.mockReturnValue(false)
mocks.pollFeatureFlags.mockReset()
mocks.pollFeatureFlags.mockResolvedValue(undefined)
mocks.getProviderSettings.mockReset()
mocks.getProviderSettings.mockReturnValue(undefined)
mocks.setApiConfiguration({})
mocks.setRemoteConfigSettings({})
mocks.setProviderSettings({})
})
function fingerprint(value: string): Fingerprint {
@@ -128,7 +135,6 @@ function deferred<T>() {
describe("ProviderCatalog Phase 3.1 cache", () => {
it("returns cache hit only when provider and fingerprint both match", async () => {
const { _testing } = await import("./catalog")
let now = 100
const cache = _testing.createProviderModelsCache({ ttlMs: 50, now: () => now })
const ollama = parseProviderId("ollama")
@@ -147,7 +153,6 @@ describe("ProviderCatalog Phase 3.1 cache", () => {
})
it("does not collide for different fingerprints", async () => {
const { _testing } = await import("./catalog")
const cache = _testing.createProviderModelsCache({ ttlMs: 50, now: () => 100 })
const providerId = parseProviderId("ollama")
const fpA = fingerprint("a")
@@ -164,7 +169,6 @@ describe("ProviderCatalog Phase 3.1 cache", () => {
})
it("reuses in-flight promise only when provider and fingerprint both match", async () => {
const { _testing } = await import("./catalog")
const cache = _testing.createProviderModelsCache({ ttlMs: 50, now: () => 100 })
const ollama = parseProviderId("ollama")
const lmstudio = parseProviderId("lmstudio")
@@ -190,7 +194,6 @@ describe("ProviderCatalog Phase 3.1 cache", () => {
})
it("returns undefined and removes expired records", async () => {
const { _testing } = await import("./catalog")
let now = 100
const cache = _testing.createProviderModelsCache({ ttlMs: 10, now: () => now })
const providerId = parseProviderId("ollama")
@@ -207,7 +210,6 @@ describe("ProviderCatalog Phase 3.1 cache", () => {
describe("ProviderCatalog Phase 3.2 resolveModels happy path", () => {
it("keeps lowercase nousresearch in extension results while using SDK casing at the SDK boundary", async () => {
const { createProviderCatalog } = await import("./catalog")
mocks.resolveProviderConfig.mockResolvedValue({
modelId: "DeepHermes-3-Llama-3-3-70B-Preview",
knownModels: {
@@ -230,7 +232,6 @@ describe("ProviderCatalog Phase 3.2 resolveModels happy path", () => {
})
it("resolves SDK knownModels, adapts model info, and uses SDK default when present", async () => {
const { createProviderCatalog } = await import("./catalog")
mocks.resolveProviderConfig.mockResolvedValue({
modelId: "sdk-default",
baseUrl: "https://provider.example.com",
@@ -271,8 +272,143 @@ describe("ProviderCatalog Phase 3.2 resolveModels happy path", () => {
)
})
it("applies remote model allowlists after SDK model resolution", async () => {
mocks.resolveProviderConfig.mockResolvedValue({
modelId: "blocked-model",
knownModels: {
"allowed-model": { id: "allowed-model", contextWindow: 128_000 },
"blocked-model": { id: "blocked-model", contextWindow: 64_000 },
},
})
mocks.setRemoteConfigSettings({
remoteProviderModelSettings: {
anthropic: {
models: [
{ id: "allowed-model", thinkingBudgetTokens: 4096 },
{ id: "remote-only-model", contextWindow: 32_000, maxTokens: 4_096 },
],
},
},
})
const providerId = parseProviderId("anthropic")
const catalog = createProviderCatalog(makeReader({ providerId }))
const result = await catalog.resolveModels(providerId)
expect(result.ok).toBe(true)
if (!result.ok) throw new Error("expected success")
expect([...result.models.keys()]).toEqual(["allowed-model", "remote-only-model"])
expect(result.defaultModelId).toBe("allowed-model")
expect(result.source).toBe("host-adapter")
expect(result.models.get("allowed-model")?.thinkingConfig?.maxBudget).toBe(4096)
expect(result.models.get("remote-only-model")).toMatchObject({ contextWindow: 32_000, maxTokens: 4_096 })
const cached = catalog.peekModels(providerId)
expect(cached?.ok).toBe(true)
if (!cached?.ok) throw new Error("expected cached success")
expect([...cached.models.keys()]).toEqual(["allowed-model", "remote-only-model"])
})
it("adds remote Bedrock custom models using base model metadata", async () => {
mocks.resolveProviderConfig.mockResolvedValue({
modelId: "anthropic.claude-sonnet-4-6",
knownModels: {
"anthropic.claude-sonnet-4-6": {
id: "anthropic.claude-sonnet-4-6",
name: "Claude Sonnet",
contextWindow: 200_000,
},
"blocked-model": { id: "blocked-model" },
},
})
mocks.setRemoteConfigSettings({
remoteProviderModelSettings: {
bedrock: {
models: [{ id: "anthropic.claude-sonnet-4-6" }],
bedrockCustomModels: [
{
name: "application-inference-profile",
baseModelId: "anthropic.claude-sonnet-4-6",
thinkingBudgetTokens: 2048,
},
],
},
},
})
const providerId = parseProviderId("bedrock")
const result = await createProviderCatalog(makeReader({ providerId })).resolveModels(providerId)
expect(result.ok).toBe(true)
if (!result.ok) throw new Error("expected success")
expect([...result.models.keys()]).toEqual(["anthropic.claude-sonnet-4-6", "application-inference-profile"])
expect(result.models.get("application-inference-profile")).toMatchObject({
name: "application-inference-profile",
contextWindow: 200_000,
thinkingConfig: { maxBudget: 2048 },
})
})
it("passes legacy OpenAI-compatible config to SDK model resolution through the SDK provider id", async () => {
mocks.setApiConfiguration({
openAiApiKey: "legacy-openai-key",
openAiBaseUrl: "https://legacy-openai.example/v1",
openAiHeaders: { "x-provider": "legacy" },
azureApiVersion: "2025-01-01-preview",
})
mocks.resolveProviderConfig.mockResolvedValue({
modelId: "custom-model",
knownModels: { "custom-model": { id: "custom-model" } },
})
const providerId = parseProviderId("openai-compatible")
const { buildEffectiveProviderConfig } = await import("./effective-config")
const result = await createProviderCatalog(makeReader(buildEffectiveProviderConfig(providerId))).resolveModels(providerId)
expect(result.ok).toBe(true)
const [, , sdkConfig] = mocks.resolveProviderConfig.mock.calls[0]
expect(sdkConfig).toMatchObject({
providerId: "openai-compatible",
apiKey: "legacy-openai-key",
baseUrl: "https://legacy-openai.example/v1",
headers: { "x-provider": "legacy" },
azure: { apiVersion: "2025-01-01-preview" },
})
})
it("omits stale Bedrock API keys from SDK model resolution when auth is IAM", async () => {
mocks.resolveProviderConfig.mockResolvedValue({
modelId: "anthropic.claude-3-7-sonnet",
knownModels: {
"anthropic.claude-3-7-sonnet": { id: "anthropic.claude-3-7-sonnet" },
},
})
const providerId = parseProviderId("bedrock")
const config: EffectiveProviderConfig = {
providerId,
apiKey: "stale-bedrock-api-key",
aws: {
authentication: "credentials",
region: "us-east-1",
},
}
const result = await createProviderCatalog(makeReader(config)).resolveModels(providerId)
expect(result.ok).toBe(true)
const [, , sdkConfig] = mocks.resolveProviderConfig.mock.calls[0]
expect(sdkConfig).toMatchObject({
providerId: "bedrock",
modelId: "",
region: "us-east-1",
aws: {
authentication: "iam",
region: "us-east-1",
},
})
expect(sdkConfig).not.toHaveProperty("apiKey")
})
it("falls back to first model when SDK default is absent", async () => {
const { createProviderCatalog } = await import("./catalog")
mocks.resolveProviderConfig.mockResolvedValue({
modelId: "missing-default",
baseUrl: "https://provider.example.com",
@@ -291,7 +427,6 @@ describe("ProviderCatalog Phase 3.2 resolveModels happy path", () => {
})
it("returns cached result without calling SDK again for the same fingerprint", async () => {
const { createProviderCatalog } = await import("./catalog")
mocks.resolveProviderConfig.mockResolvedValue({
modelId: "m",
baseUrl: "https://provider.example.com",
@@ -308,7 +443,6 @@ describe("ProviderCatalog Phase 3.2 resolveModels happy path", () => {
})
it("forceRefresh bypasses cache but still uses current fingerprint", async () => {
const { createProviderCatalog } = await import("./catalog")
mocks.resolveProviderConfig
.mockResolvedValueOnce({ modelId: "m1", knownModels: { m1: { id: "m1" } } })
.mockResolvedValueOnce({ modelId: "m2", knownModels: { m2: { id: "m2" } } })
@@ -325,7 +459,6 @@ describe("ProviderCatalog Phase 3.2 resolveModels happy path", () => {
})
it("concurrent calls with the same provider and fingerprint share one SDK call", async () => {
const { createProviderCatalog } = await import("./catalog")
const pending = deferred<{ modelId: string; knownModels: Record<string, unknown> }>()
mocks.resolveProviderConfig.mockReturnValue(pending.promise)
const providerId = parseProviderId("openrouter")
@@ -340,7 +473,6 @@ describe("ProviderCatalog Phase 3.2 resolveModels happy path", () => {
})
it("concurrent calls with different fingerprints make separate SDK calls", async () => {
const { createProviderCatalog } = await import("./catalog")
const firstPending = deferred<{ modelId: string; knownModels: Record<string, unknown> }>()
const secondPending = deferred<{ modelId: string; knownModels: Record<string, unknown> }>()
mocks.resolveProviderConfig.mockReturnValueOnce(firstPending.promise).mockReturnValueOnce(secondPending.promise)
@@ -367,7 +499,6 @@ describe("ProviderCatalog Phase 3.2 resolveModels happy path", () => {
})
it("throws before caching if loaded record does not match requested key", async () => {
const { _testing } = await import("./catalog")
const providerId = parseProviderId("openrouter")
const otherProviderId = parseProviderId("deepseek")
const fp = fingerprint("a")
@@ -379,7 +510,6 @@ describe("ProviderCatalog Phase 3.2 resolveModels happy path", () => {
describe("ProviderCatalog Phase 3.3 error path", () => {
it("SDK rejection produces an error arm", async () => {
const { createProviderCatalog } = await import("./catalog")
mocks.resolveProviderConfig.mockRejectedValue(new Error("sdk unavailable"))
const providerId = parseProviderId("openrouter")
const config: EffectiveProviderConfig = { providerId, apiKey: "same" }
@@ -394,7 +524,6 @@ describe("ProviderCatalog Phase 3.3 error path", () => {
})
it("shape validation failure produces a shape error arm", async () => {
const { createProviderCatalog } = await import("./catalog")
mocks.resolveProviderConfig.mockResolvedValue({
modelId: "bad",
knownModels: { bad: { name: "missing id" } },
@@ -409,7 +538,6 @@ describe("ProviderCatalog Phase 3.3 error path", () => {
})
it("does not cache errors; same fingerprint retries after failure", async () => {
const { createProviderCatalog } = await import("./catalog")
mocks.resolveProviderConfig
.mockRejectedValueOnce(new Error("transient"))
.mockResolvedValueOnce({ modelId: "m", knownModels: { m: { id: "m", name: "M" } } })
@@ -427,7 +555,6 @@ describe("ProviderCatalog Phase 3.3 error path", () => {
})
it("does not cache shape errors; same fingerprint retries after malformed response", async () => {
const { createProviderCatalog } = await import("./catalog")
mocks.resolveProviderConfig
.mockResolvedValueOnce({ modelId: "bad", knownModels: { bad: { name: "missing id" } } })
.mockResolvedValueOnce({ modelId: "good", knownModels: { good: { id: "good", name: "Good" } } })
@@ -447,7 +574,6 @@ describe("ProviderCatalog Phase 3.3 error path", () => {
describe("ProviderCatalog Phase 3.4 store-driven invalidation", () => {
it("fields change invalidates old-fingerprint cache and leaves the new fingerprint empty", async () => {
const { createProviderCatalog } = await import("./catalog")
mocks.resolveProviderConfig
.mockResolvedValueOnce({ modelId: "old", knownModels: { old: { id: "old", name: "Old" } } })
.mockResolvedValueOnce({ modelId: "new", knownModels: { new: { id: "new", name: "New" } } })
@@ -466,7 +592,6 @@ describe("ProviderCatalog Phase 3.4 store-driven invalidation", () => {
})
it("fields change preserves cache record for the latest fingerprint", async () => {
const { createProviderCatalog } = await import("./catalog")
mocks.resolveProviderConfig.mockResolvedValue({ modelId: "current", knownModels: { current: { id: "current" } } })
const providerId = parseProviderId("ollama")
const config: EffectiveProviderConfig = { providerId, baseUrl: "http://current.example/v1" }
@@ -482,7 +607,6 @@ describe("ProviderCatalog Phase 3.4 store-driven invalidation", () => {
})
it("fields change for one provider does not invalidate another provider", async () => {
const { createProviderCatalog } = await import("./catalog")
mocks.resolveProviderConfig.mockResolvedValue({
modelId: "openrouter-model",
knownModels: { "openrouter-model": { id: "openrouter-model" } },
@@ -502,7 +626,6 @@ describe("ProviderCatalog Phase 3.4 store-driven invalidation", () => {
})
it("selection change does not invalidate model-list cache", async () => {
const { createProviderCatalog } = await import("./catalog")
mocks.resolveProviderConfig.mockResolvedValue({ modelId: "cached", knownModels: { cached: { id: "cached" } } })
const providerId = parseProviderId("openrouter")
const reader = makeReader({ providerId, apiKey: "same" })
@@ -520,7 +643,6 @@ describe("ProviderCatalog Phase 3.4 store-driven invalidation", () => {
describe("ProviderCatalog Phase 3.5 listProviders", () => {
it("returns SDK provider listings with top-level picker metadata", async () => {
const { createProviderCatalog } = await import("./catalog")
mocks.listLocalProviders.mockResolvedValue({
providers: [
{
@@ -555,7 +677,6 @@ describe("ProviderCatalog Phase 3.5 listProviders", () => {
})
it("caches provider listings per catalog instance without reading provider config", async () => {
const { createProviderCatalog } = await import("./catalog")
mocks.listLocalProviders.mockResolvedValue({
providers: [
{
@@ -587,30 +708,167 @@ describe("ProviderCatalog Phase 3.5 listProviders", () => {
expect(reader.readSelection).not.toHaveBeenCalled()
})
it("refetches provider listings after explicit invalidation", async () => {
const { createProviderCatalog } = await import("./catalog")
it("invalidates provider listings after remote config changes", async () => {
mocks.listLocalProviders.mockResolvedValue({
providers: [
{
id: "openai-compatible",
name: "OpenAI Compatible",
protocol: "openai-chat",
client: "openai-compatible",
defaultModelId: "default",
configFields: [{ path: "baseUrl", label: "Base URL", type: "url" }],
source: "system",
},
],
})
mocks.setApiConfiguration({ openAiBaseUrl: "https://local.example/v1" })
const providerId = parseProviderId("openai-compatible")
const catalog = createProviderCatalog(makeReader({ providerId }))
const first = await catalog.listProviders()
const cached = await catalog.listProviders()
mocks.setRemoteConfigSettings({
openAiBaseUrl: "https://remote.example/v1",
})
const refreshed = await catalog.listProviders()
expect(cached).toBe(first)
expect(first[0]?.configValues).toEqual({ baseUrl: "https://local.example/v1" })
expect(refreshed).not.toBe(first)
expect(refreshed[0]?.configValues).toEqual({ baseUrl: "https://remote.example/v1" })
expect(mocks.listLocalProviders).toHaveBeenCalledTimes(2)
})
it("disables custom model ids in provider listings when remote config supplies a model allowlist", async () => {
mocks.listLocalProviders.mockResolvedValue({
providers: [
{
id: "openai-compatible",
name: "OpenAI Compatible",
protocol: "openai-chat",
client: "openai-compatible",
defaultModelId: "custom-model",
source: "system",
},
],
})
mocks.setRemoteConfigSettings({
remoteProviderModelSettings: {
"openai-compatible": {
models: [{ id: "allowed-model" }],
},
},
})
const listings = await createProviderCatalog(
makeReader({ providerId: parseProviderId("openai-compatible") }),
).listProviders()
expect(listings[0]?.allowsCustomModelIds).toBe(false)
})
it("filters providers by SDK/core auth method rather than raw llms capabilities", async () => {
mocks.listLocalProviders.mockResolvedValue({
providers: [
{
id: "openai-codex-cli",
name: "OpenAI Codex CLI",
protocol: "responses",
capabilities: ["local-auth"],
authMethod: "local",
source: "system",
},
{
id: "opencode",
name: "OpenCode",
protocol: "responses",
capabilities: ["oauth"],
authMethod: "api-key",
source: "system",
},
{
id: "claude-code",
name: "Claude Code",
protocol: "messages",
source: "system",
},
{
id: "qwen-code",
name: "Alibaba Qwen Code",
protocol: "openai-chat",
source: "system",
},
{
id: "openai-codex",
name: "OpenAI ChatGPT Subscription",
protocol: "responses",
capabilities: ["oauth"],
authMethod: "oauth",
source: "system",
},
{
id: "deepseek",
name: "DeepSeek",
protocol: "openai-chat",
source: "system",
},
],
})
const catalog = createProviderCatalog(makeReader({ providerId: parseProviderId("deepseek") }))
const listings = await catalog.listProviders()
expect(listings.map((provider) => provider.id)).toEqual([
parseProviderId("opencode"),
parseProviderId("openai-codex"),
parseProviderId("deepseek"),
])
})
it("invalidates provider listings after provider settings change", async () => {
mocks.listLocalProviders
.mockResolvedValueOnce({
providers: [{ id: "cline", name: "Cline", protocol: "anthropic", client: "anthropic", source: "system" }],
providers: [
{
id: "ollama",
name: "Ollama",
protocol: "openai-chat",
client: "openai-compatible",
defaultModelId: "default-a",
source: "system",
},
],
})
.mockResolvedValueOnce({
providers: [
{ id: "cline-pass", name: "ClinePass", protocol: "anthropic", client: "anthropic", source: "system" },
{
id: "ollama",
name: "Ollama",
protocol: "openai-chat",
client: "openai-compatible",
defaultModelId: "default-b",
source: "system",
},
],
})
const catalog = createProviderCatalog(makeReader({ providerId: parseProviderId("cline") }))
const providerId = parseProviderId("ollama")
const reader = makeReader({ providerId, baseUrl: "http://old.example" })
const catalog = createProviderCatalog(reader)
const first = await catalog.listProviders()
catalog.invalidateProviderListings()
const second = await catalog.listProviders()
const cached = await catalog.listProviders()
reader.setConfig({ providerId, baseUrl: "http://new.example" })
reader.emit({ kind: "fields", providerId, config: { providerId, baseUrl: "http://new.example" } })
const refreshed = await catalog.listProviders()
expect(first[0]?.id).toBe("cline")
expect(second[0]?.id).toBe("cline-pass")
expect(cached).toBe(first)
expect(first[0]?.defaultModelId).toBe("default-a")
expect(refreshed[0]?.defaultModelId).toBe("default-b")
expect(mocks.listLocalProviders).toHaveBeenCalledTimes(2)
})
it("retries provider listing after an SDK listing failure", async () => {
const { createProviderCatalog } = await import("./catalog")
mocks.listLocalProviders.mockRejectedValueOnce(new Error("temporary catalog failure")).mockResolvedValueOnce({
providers: [
{
@@ -641,7 +899,6 @@ describe("ProviderCatalog Phase 3.5 listProviders", () => {
describe("ProviderCatalog Phase 3.6 subscribe", () => {
it("fires the provider listener after resolveModels completes", async () => {
const { createProviderCatalog } = await import("./catalog")
mocks.resolveProviderConfig.mockResolvedValue({ modelId: "model-a", knownModels: { "model-a": { id: "model-a" } } })
const providerId = parseProviderId("deepseek")
const catalog = createProviderCatalog(makeReader({ providerId, apiKey: "key" }))
@@ -655,7 +912,6 @@ describe("ProviderCatalog Phase 3.6 subscribe", () => {
})
it("fires after a cache-hit resolveModels result", async () => {
const { createProviderCatalog } = await import("./catalog")
mocks.resolveProviderConfig.mockResolvedValue({ modelId: "cached", knownModels: { cached: { id: "cached" } } })
const providerId = parseProviderId("openrouter")
const catalog = createProviderCatalog(makeReader({ providerId, apiKey: "same" }))
@@ -673,7 +929,6 @@ describe("ProviderCatalog Phase 3.6 subscribe", () => {
})
it("does not fire provider listener for another provider", async () => {
const { createProviderCatalog } = await import("./catalog")
mocks.resolveProviderConfig.mockResolvedValue({ modelId: "model-a", knownModels: { "model-a": { id: "model-a" } } })
const subscribedProvider = parseProviderId("deepseek")
const resolvedProvider = parseProviderId("openrouter")
@@ -687,7 +942,6 @@ describe("ProviderCatalog Phase 3.6 subscribe", () => {
})
it("does not fire model-list listener when only commitSelection happens", async () => {
const { createProviderCatalog } = await import("./catalog")
const providerId = parseProviderId("ollama")
const reader = makeReader({ providerId, baseUrl: "http://localhost:11434/v1" })
const catalog = createProviderCatalog(reader)
@@ -701,7 +955,6 @@ describe("ProviderCatalog Phase 3.6 subscribe", () => {
})
it("disposable unregisters the provider listener", async () => {
const { createProviderCatalog } = await import("./catalog")
mocks.resolveProviderConfig.mockResolvedValue({ modelId: "model-a", knownModels: { "model-a": { id: "model-a" } } })
const providerId = parseProviderId("deepseek")
const catalog = createProviderCatalog(makeReader({ providerId, apiKey: "key" }))
+278 -41
View File
@@ -1,8 +1,16 @@
import { listLocalProviders, type ModelCatalogConfig, resolveProviderConfig } from "@cline/core"
import { type ProviderConfig, resolveProviderUsageCostDisplay } from "@cline/llms"
import { type ProviderListItem } from "@cline/shared"
import { type ModelInfo, openAiModelInfoSafeDefaults } from "@shared/api"
import { isVscodeUnsupportedProvider } from "@shared/model-catalog/provider-helpers"
import { StateManager } from "@/core/storage/StateManager"
import { getFeatureFlagsService } from "@/services/feature-flags"
import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
import type {
RemoteBedrockCustomModelEntry,
RemoteProviderModelEntry,
RemoteProviderModelSettings,
} from "@/shared/storage/state-keys"
import { getProviderSettingsManager } from "../provider-migration"
import type {
CatalogError,
@@ -18,7 +26,7 @@ import type {
ProviderModelsResult,
UsageCostDisplay,
} from "./contracts"
import { providerAllowsCustomModelIds } from "./custom-model-ids"
import { buildEffectiveProviderConfig } from "./effective-config"
import { computeConfigFingerprint } from "./fingerprint"
import { applyHostModelInfoOverrides } from "./host-overrides"
import { parseProviderId } from "./provider-id"
@@ -41,7 +49,6 @@ interface ResolveRecordOptions {
load(): Promise<ProviderModelsRecord>
}
const DEFAULT_MODEL_CACHE_TTL_MS = 5 * 60 * 1000
const DEFAULT_MODEL_CATALOG_CONFIG: ModelCatalogConfig = {
loadLatestOnInit: true,
loadPrivateOnAuth: true,
@@ -49,6 +56,31 @@ const DEFAULT_MODEL_CATALOG_CONFIG: ModelCatalogConfig = {
cacheTtlMs: 0,
}
// Providers whose model id is user-supplied free text rather than a fixed
// catalog selection. The SDK catalog for these either has no curated model
// list (openai-compatible: bring-your-own base URL + model) or a host-fetched
// list that the user can also bypass (ollama/lmstudio/litellm). For these,
// the picker must allow arbitrary model ids and model resolution must honor
// the requested id instead of coercing to the catalog default.
const CUSTOM_MODEL_ID_PROVIDER_IDS = new Set(["openai-compatible", "ollama", "lmstudio", "litellm", "bedrock"])
/**
* Whether a provider id accepts a user-supplied (custom) model id. Exported so
* model-resolution code paths (e.g. `resolveModelInfo`) can honor a custom id
* for these providers rather than falling back to the SDK catalog default.
*
* Accepts either the extension or SDK provider id spelling (the extension's
* `openai` maps to the SDK's `openai-compatible`).
*/
export function providerAllowsCustomModelIds(providerId: string): boolean {
const parsedProviderId = parseProviderId(providerId)
return CUSTOM_MODEL_ID_PROVIDER_IDS.has(toSdkProviderId(providerId)) && !hasRemoteModelAllowlist(parsedProviderId)
}
export function providerHasRemoteModelAllowlist(providerId: string): boolean {
return hasRemoteModelAllowlist(parseProviderId(providerId))
}
/**
* Normalize the SDK's usage-cost-display answer (string union) into the
* extension's {@link UsageCostDisplay} type. The SDK function takes a
@@ -63,6 +95,60 @@ function makeCacheKey(providerId: ProviderId, fingerprint: Fingerprint): CacheKe
return `${providerId}:${fingerprint}`
}
function isSdkAwsAuthentication(value: unknown): value is NonNullable<ProviderConfig["aws"]>["authentication"] {
return value === "iam" || value === "api-key" || value === "apikey" || value === "profile"
}
function normalizeSdkAwsAuthentication(value: unknown): NonNullable<ProviderConfig["aws"]>["authentication"] | undefined {
if (value === "credentials") {
return "iam"
}
return isSdkAwsAuthentication(value) ? value : undefined
}
function toSdkAwsConfig(config: EffectiveProviderConfig["aws"]): ProviderConfig["aws"] {
if (!config) {
return undefined
}
return {
...config,
authentication: normalizeSdkAwsAuthentication(config.authentication),
}
}
function isBedrockApiKeyAuthentication(authentication: unknown): boolean {
return authentication === "api-key" || authentication === "apikey"
}
function isSdkSapApi(value: unknown): value is NonNullable<ProviderConfig["sap"]>["api"] {
return value === "orchestration" || value === "foundation-models"
}
function toSdkSapConfig(config: EffectiveProviderConfig["sap"]): ProviderConfig["sap"] {
if (!config) {
return undefined
}
return {
...config,
api: isSdkSapApi(config.api) ? config.api : undefined,
defaultSettings: config.defaultSettings ? { ...config.defaultSettings } : undefined,
}
}
function isSdkOcaMode(value: unknown): value is NonNullable<ProviderConfig["oca"]>["mode"] {
return value === "internal" || value === "external"
}
function toSdkOcaConfig(config: EffectiveProviderConfig["oca"]): ProviderConfig["oca"] {
if (!config) {
return undefined
}
return {
...config,
mode: isSdkOcaMode(config.mode) ? config.mode : undefined,
}
}
function assertRecordMatchesRequest(record: ProviderModelsRecord, providerId: ProviderId, fingerprint: Fingerprint): void {
if (record.providerId !== providerId || record.configFingerprint !== fingerprint) {
throw new Error(
@@ -143,18 +229,124 @@ function createProviderModelsCache(options: ProviderModelsCacheOptions) {
}
function toSdkProviderConfig(config: EffectiveProviderConfig, selection: ModelSelection | undefined): ProviderConfig {
const providerId = toSdkProviderId(config.providerId)
const aws = toSdkAwsConfig(config.aws)
const apiKey = providerId === "bedrock" && !isBedrockApiKeyAuthentication(aws?.authentication) ? undefined : config.apiKey
return {
providerId: toSdkProviderId(config.providerId),
providerId,
modelId: selection?.modelId ?? "",
apiKey: config.apiKey,
...(apiKey ? { apiKey } : {}),
baseUrl: config.baseUrl,
headers: config.headers ? { ...config.headers } : undefined,
accessToken: config.auth?.accessToken,
refreshToken: config.auth?.refreshToken,
accountId: config.auth?.accountId,
apiLine: config.apiLine === "china" || config.apiLine === "international" ? config.apiLine : undefined,
region: config.gcp?.region ?? config.region,
region: config.gcp?.region ?? config.aws?.region ?? config.region,
gcp: config.gcp ? { ...config.gcp } : undefined,
azure: config.azure ? { ...config.azure } : undefined,
aws,
sap: toSdkSapConfig(config.sap),
oca: toSdkOcaConfig(config.oca),
}
}
function readRemoteProviderModelSettings(providerId: ProviderId): RemoteProviderModelSettings[string] | undefined {
try {
const manager = StateManager.get() as { getRemoteConfigSettings?: () => unknown }
const settings = manager.getRemoteConfigSettings?.()
if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
return undefined
}
const remoteProviderModelSettings = (settings as { remoteProviderModelSettings?: RemoteProviderModelSettings })
.remoteProviderModelSettings
return remoteProviderModelSettings?.[toSdkProviderId(providerId)] ?? remoteProviderModelSettings?.[providerId]
} catch {
return undefined
}
}
function readRemoteAllowedModelIdsForProvider(providerId: ProviderId): readonly string[] {
const settings = readRemoteProviderModelSettings(providerId)
return [
...(settings?.models ?? []).map((model) => model.id),
...(settings?.bedrockCustomModels ?? []).map((model) => model.name),
].filter((modelId) => modelId.trim().length > 0)
}
export function readRemoteAllowedModelIds(providerId: string): readonly string[] {
return readRemoteAllowedModelIdsForProvider(parseProviderId(providerId))
}
function hasRemoteModelAllowlist(providerId: ProviderId): boolean {
return readRemoteAllowedModelIdsForProvider(providerId).length > 0
}
function readRemoteConfigCacheKey(): string {
try {
const manager = StateManager.get() as { getRemoteConfigSettings?: () => unknown }
return JSON.stringify(manager.getRemoteConfigSettings?.() ?? {}) ?? "{}"
} catch {
return "{}"
}
}
function withRemoteModelInfo(base: ModelInfo | undefined, entry: RemoteProviderModelEntry): ModelInfo {
const next: ModelInfo & { isR1FormatRequired?: boolean } = {
...(base ?? openAiModelInfoSafeDefaults),
name: base?.name ?? entry.id,
}
if (entry.contextWindow !== undefined) next.contextWindow = entry.contextWindow
if (entry.maxTokens !== undefined) next.maxTokens = entry.maxTokens
if (entry.inputPrice !== undefined) next.inputPrice = entry.inputPrice
if (entry.outputPrice !== undefined) next.outputPrice = entry.outputPrice
if (entry.supportsImages !== undefined) next.supportsImages = entry.supportsImages
if (entry.promptCachingEnabled !== undefined) next.supportsPromptCache = entry.promptCachingEnabled
if (entry.temperature !== undefined) next.temperature = entry.temperature
if (entry.isR1FormatRequired !== undefined) next.isR1FormatRequired = entry.isR1FormatRequired
if (entry.thinkingBudgetTokens !== undefined) {
next.thinkingConfig = { ...(next.thinkingConfig ?? {}), maxBudget: entry.thinkingBudgetTokens }
}
return next
}
function withRemoteBedrockCustomModelInfo(
models: ReadonlyMap<string, ModelInfo>,
entry: RemoteBedrockCustomModelEntry,
): ModelInfo {
const base = models.get(entry.baseModelId) ?? openAiModelInfoSafeDefaults
return {
...base,
name: entry.name,
description: base.description
? `${base.description} Base model: ${entry.baseModelId}.`
: `Base model: ${entry.baseModelId}.`,
...(entry.thinkingBudgetTokens !== undefined
? { thinkingConfig: { ...(base.thinkingConfig ?? {}), maxBudget: entry.thinkingBudgetTokens } }
: {}),
}
}
function applyRemoteModelSettings(record: ProviderModelsRecord, providerId: ProviderId): ProviderModelsRecord {
const settings = readRemoteProviderModelSettings(providerId)
if (!settings?.models?.length && !settings?.bedrockCustomModels?.length) {
return record
}
const nextModels = new Map<string, ModelInfo>()
for (const entry of settings.models ?? []) {
nextModels.set(entry.id, withRemoteModelInfo(record.models.get(entry.id), entry))
}
for (const customModel of settings.bedrockCustomModels ?? []) {
nextModels.set(customModel.name, withRemoteBedrockCustomModelInfo(record.models, customModel))
}
return {
...record,
models: nextModels,
defaultModelId: nextModels.has(record.defaultModelId) ? record.defaultModelId : (nextModels.keys().next().value ?? ""),
source: "host-adapter",
}
}
@@ -170,33 +362,73 @@ function optionalNonEmpty(value: string | undefined): string | undefined {
return trimmed ? trimmed : undefined
}
function isProviderConfigFieldValue(value: unknown): boolean {
return typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value === null
}
function readConfigPathValue(config: EffectiveProviderConfig, path: string): unknown {
let current: unknown = config
for (const segment of path.split(".")) {
if (typeof current !== "object" || current === null || Array.isArray(current)) {
return undefined
}
current = (current as Record<string, unknown>)[segment]
}
return isProviderConfigFieldValue(current) || path === "headers" ? current : undefined
}
function readEffectiveConfigValues(provider: ProviderListItem): Record<string, unknown> | undefined {
const fields = provider.configFields?.filter((field) => field.path && !field.secret)
if (!fields?.length) {
return undefined
}
const effectiveConfig = buildEffectiveProviderConfig(parseProviderId(provider.id))
const values: Record<string, unknown> = {}
for (const field of fields) {
const value = readConfigPathValue(effectiveConfig, field.path)
if (value !== undefined) {
values[field.path] = value
}
}
return Object.keys(values).length > 0 ? values : undefined
}
function toProviderListing(provider: ProviderListItem): ProviderListing {
const effectiveConfigValues = readEffectiveConfigValues(provider)
const providerId = parseProviderId(provider.id)
return {
id: parseProviderId(provider.id),
id: providerId,
name: provider.name,
defaultModelId: optionalNonEmpty(provider.defaultModelId),
protocol: provider.protocol,
// ProviderListing intentionally does not include full model-list data.
// Reuse the lightweight description slot until the RPC-facing picker
// contract decides whether it needs a generic provider description field.
authMethod: provider.authMethod,
authDescription: optionalNonEmpty(provider.authDescription),
// The SDK has the right signal for this on each provider (e.g.
// `modelsSourceUrl` for ollama/lmstudio, or the `openai-compatible`
// family with no curated catalog), but does not yet expose it
// through a public helper. Until then this set is the host-side
// fallback; remove it as soon as upstream exposes the signal.
allowsCustomModelIds: providerAllowsCustomModelIds(provider.id),
baseUrlDescription: optionalNonEmpty(provider.baseUrlDescription),
configFields: provider.configFields,
configValues: effectiveConfigValues
? { ...(provider.configValues ?? {}), ...effectiveConfigValues }
: provider.configValues,
allowsCustomModelIds: CUSTOM_MODEL_ID_PROVIDER_IDS.has(providerId) && !hasRemoteModelAllowlist(providerId),
usageCostDisplay: readUsageCostDisplay(provider.id),
}
}
async function listSdkProviderListings(): Promise<ReadonlyArray<ProviderListing>> {
function isVscodeSupportedProvider(provider: ProviderListItem): boolean {
if (isVscodeUnsupportedProvider(provider.id)) {
return false
}
if (provider.authMethod === "local") {
return false
}
return true
}
function listSdkProviderListings(): Promise<ReadonlyArray<ProviderListing>> {
const manager = getProviderSettingsManager()
const featureFlags = getFeatureFlagsService()
const { providers } = await listLocalProviders(manager, {
isClinePassEnabled: featureFlags.getBooleanFlagEnabled(FeatureFlag.CLINE_PASS),
})
return providers.map(toProviderListing)
return listLocalProviders(manager, {
isClinePassEnabled: getFeatureFlagsService().getBooleanFlagEnabled(FeatureFlag.CLINE_PASS),
}).then(({ providers }) => providers.filter(isVscodeSupportedProvider).map(toProviderListing))
}
async function resolveSdkModels(
@@ -249,16 +481,6 @@ function toCatalogError(error: unknown): CatalogError {
}
}
/**
* Internal test hook for cache/in-flight behavior. Not part of the public
* model-catalog API; production callers should use createProviderCatalog.
*/
export const _testing = {
createProviderModelsCache,
makeCacheKey,
assertRecordMatchesRequest,
}
/**
* Create a {@link ProviderCatalog}.
*
@@ -268,8 +490,9 @@ export const _testing = {
*/
export function createProviderCatalog(reader: ProviderConfigReader): ProviderCatalog {
const now = () => Date.now()
const cache = createProviderModelsCache({ ttlMs: DEFAULT_MODEL_CACHE_TTL_MS, now })
const cache = createProviderModelsCache({ ttlMs: 5 * 60 * 1000, now })
let providerListingsPromise: Promise<ReadonlyArray<ProviderListing>> | undefined
let providerListingsRemoteConfigKey: string | undefined
const modelListeners = new Map<ProviderId, Set<(event: ProviderModelsEvent) => void>>()
function notifyModelListeners(providerId: ProviderId, result: ProviderModelsResult): void {
@@ -287,23 +510,25 @@ export function createProviderCatalog(reader: ProviderConfigReader): ProviderCat
if (event.kind !== "fields") {
return
}
providerListingsPromise = undefined
const latestConfig = reader.read(event.providerId)
const latestFingerprint = computeConfigFingerprint(event.providerId, latestConfig)
cache.invalidateProviderExcept(event.providerId, latestFingerprint)
})
return {
async listProviders(): Promise<ReadonlyArray<ProviderListing>> {
providerListingsPromise ??= listSdkProviderListings().catch((error) => {
providerListingsPromise = undefined
throw error
})
const remoteConfigKey = readRemoteConfigCacheKey()
if (!providerListingsPromise || providerListingsRemoteConfigKey !== remoteConfigKey) {
providerListingsRemoteConfigKey = remoteConfigKey
providerListingsPromise = listSdkProviderListings().catch((error) => {
providerListingsPromise = undefined
providerListingsRemoteConfigKey = undefined
throw error
})
}
return providerListingsPromise
},
invalidateProviderListings(): void {
providerListingsPromise = undefined
},
async resolveModels(
providerId: ProviderId,
options?: { readonly forceRefresh?: boolean },
@@ -322,6 +547,7 @@ export function createProviderCatalog(reader: ProviderConfigReader): ProviderCat
forceRefresh: options?.forceRefresh,
load: () => resolveSdkModels(providerId, fingerprint, config, selection, now),
})
result = applyRemoteModelSettings(result, providerId)
} catch (error) {
result = {
ok: false,
@@ -338,7 +564,8 @@ export function createProviderCatalog(reader: ProviderConfigReader): ProviderCat
peekModels(providerId: ProviderId): ProviderModelsResult | undefined {
const config = reader.read(providerId)
const fingerprint = computeConfigFingerprint(providerId, config)
return cache.peek(providerId, fingerprint)
const result = cache.peek(providerId, fingerprint)
return result ? applyRemoteModelSettings(result, providerId) : undefined
},
subscribe(providerId: ProviderId, listener: (event: ProviderModelsEvent) => void): Disposable {
@@ -359,3 +586,13 @@ export function createProviderCatalog(reader: ProviderConfigReader): ProviderCat
},
}
}
/**
* Internal test hook for cache/in-flight behavior. Not part of the public
* model-catalog API; production callers should use createProviderCatalog.
*/
export const _testing = {
createProviderModelsCache,
makeCacheKey,
assertRecordMatchesRequest,
}
+33 -6
View File
@@ -11,6 +11,7 @@
* those casts are never necessary or correct.
*/
import type { ProviderConfigField } from "@cline/shared"
import type { ModelInfo } from "@shared/api"
import type { Mode } from "@shared/storage/types"
@@ -75,6 +76,7 @@ export interface AwsProviderConfig {
readonly accessKey?: string
readonly secretKey?: string
readonly sessionToken?: string
readonly region?: string
readonly authentication?: "iam" | "api-key" | "apikey" | "profile" | string
readonly profile?: string
readonly usePromptCache?: boolean
@@ -89,6 +91,26 @@ export interface GcpProviderConfig {
readonly region?: string
}
export interface AzureProviderConfig {
readonly apiVersion?: string
}
export interface SapProviderConfig {
readonly clientId?: string
readonly clientSecret?: string
readonly tokenUrl?: string
readonly resourceGroup?: string
readonly deploymentId?: string
readonly useOrchestrationMode?: boolean
readonly api?: "orchestration" | "foundation-models" | string
readonly defaultSettings?: Readonly<Record<string, unknown>>
}
export interface OcaProviderConfig {
readonly mode?: "internal" | "external" | string
readonly usePromptCache?: boolean
}
export interface EffectiveProviderConfig {
readonly providerId: ProviderId
readonly apiKey?: string
@@ -98,6 +120,9 @@ export interface EffectiveProviderConfig {
readonly region?: string
readonly aws?: AwsProviderConfig
readonly gcp?: GcpProviderConfig
readonly azure?: AzureProviderConfig
readonly sap?: SapProviderConfig
readonly oca?: OcaProviderConfig
/**
* OAuth-style auth bundle (e.g. cline provider's WorkOS token).
* Compatible with `apiKey`; some providers populate both.
@@ -133,6 +158,8 @@ interface ProviderReasoningPatch {
}
export interface ProviderConfigPatch {
readonly mode?: Mode
readonly settings?: Readonly<Record<string, unknown>>
readonly apiKey?: string | null
readonly baseUrl?: string | null
readonly apiLine?: string | null
@@ -140,6 +167,9 @@ export interface ProviderConfigPatch {
readonly region?: string | null
readonly aws?: AwsProviderConfig | null
readonly gcp?: GcpProviderConfig | null
readonly azure?: AzureProviderConfig | null
readonly sap?: SapProviderConfig | null
readonly oca?: OcaProviderConfig | null
readonly auth?: {
readonly accessToken?: string
readonly refreshToken?: string
@@ -226,8 +256,11 @@ export interface ProviderListing {
readonly defaultModelId?: string
readonly family?: string
readonly protocol?: string
readonly authMethod?: "api-key" | "oauth" | "local"
readonly authDescription?: string
readonly baseUrlDescription?: string
readonly configFields?: readonly ProviderConfigField[]
readonly configValues?: Readonly<Record<string, unknown>>
/**
* Whether arbitrary model ids are meaningful for this provider. Drives
* the manual-entry affordance in `ModelPickerWithManualEntry`.
@@ -384,12 +417,6 @@ export interface ProviderCatalog {
*/
listProviders(): Promise<ReadonlyArray<ProviderListing>>
/**
* Clear cached provider listings so the next list request re-applies
* feature-flag-gated provider visibility.
*/
invalidateProviderListings(): void
/**
* Resolve models for a provider given the current effective config.
* Reads effective config from the store; callers do not pass it.
@@ -1,23 +0,0 @@
import { toSdkProviderId } from "./sdk-provider-id"
// Providers whose model id is user-supplied free text rather than a fixed
// catalog selection. The SDK catalog for these either has no curated model
// list (openai-compatible: bring-your-own base URL + model) or a host-fetched
// list that the user can also bypass (ollama/lmstudio/litellm). For these,
// the picker must allow arbitrary model ids and model resolution must honor
// the requested id instead of coercing to the catalog default.
const CUSTOM_MODEL_ID_PROVIDER_IDS = new Set(["openai-compatible", "ollama", "lmstudio", "litellm"])
/**
* Whether a provider id accepts a user-supplied (custom) model id.
*
* Keep this helper in a side-effect-free module so lightweight controller
* handlers (e.g. `resolveModelInfo`) do not import the full provider catalog
* implementation and its feature-flag/controller dependency graph.
*
* Accepts either the extension or SDK provider id spelling (the extension's
* `openai` maps to the SDK's `openai-compatible`).
*/
export function providerAllowsCustomModelIds(providerId: string): boolean {
return CUSTOM_MODEL_ID_PROVIDER_IDS.has(toSdkProviderId(providerId))
}
@@ -4,17 +4,24 @@ import { parseProviderId } from "./provider-id"
const mocks = vi.hoisted(() => {
let apiConfiguration: ApiConfiguration = {}
let remoteConfigSettings: ApiConfiguration = {}
let providerSettingsById: Record<string, unknown> = {}
return {
setApiConfiguration(value: ApiConfiguration): void {
apiConfiguration = value
},
setRemoteConfigSettings(value: ApiConfiguration): void {
remoteConfigSettings = value
},
setProviderSettings(value: Record<string, unknown>): void {
providerSettingsById = value
},
getStateManager() {
return { getApiConfiguration: () => apiConfiguration }
return {
getApiConfiguration: () => apiConfiguration,
getRemoteConfigSettings: () => remoteConfigSettings,
}
},
getProviderSettingsManager() {
return { getProviderSettings: (providerId: string) => providerSettingsById[providerId] }
@@ -33,10 +40,11 @@ vi.mock("../provider-migration", () => ({
describe("buildEffectiveProviderConfig", () => {
beforeEach(() => {
mocks.setApiConfiguration({})
mocks.setRemoteConfigSettings({})
mocks.setProviderSettings({})
})
it("builds Ollama config with StateManager base URL over providers.json and local extras", async () => {
it("builds Ollama config from SDK provider settings when a provider record exists", async () => {
const { buildEffectiveProviderConfig } = await import("./effective-config")
mocks.setProviderSettings({
ollama: {
@@ -53,12 +61,11 @@ describe("buildEffectiveProviderConfig", () => {
expect(buildEffectiveProviderConfig(parseProviderId("ollama"))).toEqual({
providerId: parseProviderId("ollama"),
apiKey: "provider-ollama-key",
baseUrl: "http://state-ollama:11434",
extras: { ollamaApiOptionsCtxNum: "8192" },
baseUrl: "http://provider-ollama:11434",
})
})
it("builds LiteLLM config by merging providers.json fields and StateManager overlays", async () => {
it("builds LiteLLM config from SDK provider settings without StateManager overlays", async () => {
const { buildEffectiveProviderConfig } = await import("./effective-config")
mocks.setProviderSettings({
litellm: {
@@ -77,72 +84,72 @@ describe("buildEffectiveProviderConfig", () => {
expect(buildEffectiveProviderConfig(parseProviderId("litellm"))).toEqual({
providerId: parseProviderId("litellm"),
apiKey: "provider-litellm-key",
baseUrl: "https://state-litellm.example.com/v1",
baseUrl: "https://provider-litellm.example.com/v1",
headers: { "x-provider": "provider-header" },
extras: { providerOnly: true, liteLlmUsePromptCache: true },
extras: { providerOnly: true },
})
})
it("uses StateManager DeepSeek API key over providers.json", async () => {
it("uses SDK provider settings over stale StateManager DeepSeek keys", async () => {
const { buildEffectiveProviderConfig } = await import("./effective-config")
mocks.setProviderSettings({ deepseek: { provider: "deepseek", apiKey: "provider-deepseek-key" } })
mocks.setApiConfiguration({ deepSeekApiKey: "state-deepseek-key" })
expect(buildEffectiveProviderConfig(parseProviderId("deepseek"))).toEqual({
providerId: parseProviderId("deepseek"),
apiKey: "state-deepseek-key",
apiKey: "provider-deepseek-key",
})
})
it("reads normalized nousResearch API key from StateManager", async () => {
it("reads normalized nousResearch API key from SDK provider settings", async () => {
const { buildEffectiveProviderConfig } = await import("./effective-config")
mocks.setProviderSettings({ nousresearch: { provider: "nousresearch", apiKey: "provider-nous-key" } })
mocks.setApiConfiguration({ nousResearchApiKey: "state-nous-key" })
expect(buildEffectiveProviderConfig(parseProviderId("nousResearch"))).toEqual({
providerId: parseProviderId("nousResearch"),
apiKey: "state-nous-key",
apiKey: "provider-nous-key",
})
})
it("carries Qwen apiLine from StateManager effective configuration", async () => {
it("hydrates OpenAI-compatible SDK config from provider settings", async () => {
const { buildEffectiveProviderConfig } = await import("./effective-config")
mocks.setProviderSettings({
"openai-compatible": {
provider: "openai-compatible",
apiKey: "provider-openai-key",
baseUrl: "https://provider.example/v1",
azure: { apiVersion: "2024-02-15-preview" },
},
})
mocks.setApiConfiguration({
openAiApiKey: "state-openai-key",
openAiBaseUrl: "https://state.example/v1",
openAiHeaders: { "x-provider": "state" },
azureApiVersion: "2025-01-01-preview",
})
expect(buildEffectiveProviderConfig(parseProviderId("openai-compatible"))).toEqual({
providerId: parseProviderId("openai-compatible"),
apiKey: "provider-openai-key",
baseUrl: "https://provider.example/v1",
azure: { apiVersion: "2024-02-15-preview" },
})
})
it("carries Qwen apiLine from SDK provider settings", async () => {
const { buildEffectiveProviderConfig } = await import("./effective-config")
mocks.setProviderSettings({ qwen: { provider: "qwen", apiKey: "provider-qwen-key", apiLine: "china" } })
mocks.setApiConfiguration({ qwenApiKey: "state-qwen-key", qwenApiLine: "international" })
expect(buildEffectiveProviderConfig(parseProviderId("qwen"))).toEqual({
providerId: parseProviderId("qwen"),
apiKey: "state-qwen-key",
apiLine: "international",
apiKey: "provider-qwen-key",
apiLine: "china",
})
})
it("reads the Z.AI Coding Plan API key from provider-specific settings", async () => {
const { buildEffectiveProviderConfig } = await import("./effective-config")
mocks.setProviderSettings({
"zai-coding-plan": { provider: "zai-coding-plan", apiKey: "provider-zai-coding-plan-key" },
})
mocks.setApiConfiguration({ zaiApiKey: "state-zai-key" })
expect(buildEffectiveProviderConfig(parseProviderId("zai-coding-plan"))).toEqual({
providerId: parseProviderId("zai-coding-plan"),
apiKey: "provider-zai-coding-plan-key",
})
})
it("does not reuse the legacy Z.AI API key for Z.AI Coding Plan", async () => {
const { buildEffectiveProviderConfig } = await import("./effective-config")
mocks.setProviderSettings({
"zai-coding-plan": { provider: "zai-coding-plan" },
})
mocks.setApiConfiguration({ zaiApiKey: "state-zai-key" })
expect(buildEffectiveProviderConfig(parseProviderId("zai-coding-plan"))).toEqual({
providerId: parseProviderId("zai-coding-plan"),
})
})
it("respects remote-config-locked LiteLLM key already applied by StateManager", async () => {
it("respects remote-config-locked LiteLLM key over SDK provider settings", async () => {
const { buildEffectiveProviderConfig } = await import("./effective-config")
mocks.setProviderSettings({
litellm: {
@@ -151,7 +158,7 @@ describe("buildEffectiveProviderConfig", () => {
baseUrl: "https://provider-litellm.example.com/v1",
},
})
mocks.setApiConfiguration({
mocks.setRemoteConfigSettings({
liteLlmApiKey: "remote-config-locked-litellm-key",
liteLlmBaseUrl: "https://remote-litellm.example.com/v1",
})
@@ -163,6 +170,24 @@ describe("buildEffectiveProviderConfig", () => {
})
})
it("falls back to legacy StateManager provider fields when SDK provider settings do not exist", async () => {
const { buildEffectiveProviderConfig } = await import("./effective-config")
mocks.setApiConfiguration({
openAiApiKey: "state-openai-key",
openAiBaseUrl: "https://state.example/v1",
openAiHeaders: { "x-provider": "state" },
azureApiVersion: "2025-01-01-preview",
})
expect(buildEffectiveProviderConfig(parseProviderId("openai-compatible"))).toEqual({
providerId: parseProviderId("openai-compatible"),
apiKey: "state-openai-key",
baseUrl: "https://state.example/v1",
headers: { "x-provider": "state" },
azure: { apiVersion: "2025-01-01-preview" },
})
})
it("keeps Cline account auth in the auth envelope", async () => {
const { buildEffectiveProviderConfig } = await import("./effective-config")
mocks.setApiConfiguration({ clineApiKey: "cline-access-token", clineAccountId: "account-123" })
@@ -1,7 +1,17 @@
import type { ApiConfiguration } from "@shared/api"
import { StateManager } from "@/core/storage/StateManager"
import { toLegacyApiProvider } from "@/shared/model-catalog/provider-helpers"
import { getProviderSettingsManager } from "../provider-migration"
import type { AwsProviderConfig, EffectiveProviderConfig, GcpProviderConfig, ProviderId } from "./contracts"
import type {
AwsProviderConfig,
AzureProviderConfig,
EffectiveProviderConfig,
GcpProviderConfig,
OcaProviderConfig,
ProviderId,
SapProviderConfig,
} from "./contracts"
import { toSdkProviderId } from "./sdk-provider-id"
type AuthConfig = NonNullable<EffectiveProviderConfig["auth"]>
type ExtrasConfig = NonNullable<EffectiveProviderConfig["extras"]>
@@ -17,16 +27,23 @@ type ProviderSettingsLike = {
readonly region?: string
readonly aws?: AwsProviderConfig
readonly gcp?: GcpProviderConfig
readonly azure?: AzureProviderConfig
readonly sap?: SapProviderConfig
readonly oca?: OcaProviderConfig
readonly auth?: AuthConfig
readonly extras?: ExtrasConfig
}
type ProviderSettingsRead = {
readonly exists: boolean
readonly config: ConfigParts
}
const apiKeyFields: Partial<Record<string, keyof ApiConfiguration>> = {
anthropic: "apiKey",
openrouter: "openRouterApiKey",
openai: "openAiApiKey",
"openai-native": "openAiNativeApiKey",
"openai-codex": "openAiNativeApiKey",
bedrock: "awsBedrockApiKey",
gemini: "geminiApiKey",
deepseek: "deepSeekApiKey",
@@ -55,6 +72,7 @@ const apiKeyFields: Partial<Record<string, keyof ApiConfiguration>> = {
hicap: "hicapApiKey",
aihubmix: "aihubmixApiKey",
nousresearch: "nousResearchApiKey",
nousResearch: "nousResearchApiKey",
"vercel-ai-gateway": "vercelAiGatewayApiKey",
wandb: "wandbApiKey",
oca: "ocaApiKey",
@@ -174,6 +192,50 @@ function readGcp(record: Record<string, unknown>): GcpProviderConfig | undefined
return Object.values(result).some((value) => value !== undefined) ? result : undefined
}
function readAzure(record: Record<string, unknown>): AzureProviderConfig | undefined {
const azure = record.azure
if (!isPlainRecord(azure)) {
return undefined
}
const result: AzureProviderConfig = {
apiVersion: readString(azure, "apiVersion"),
}
return Object.values(result).some((value) => value !== undefined) ? result : undefined
}
function readSap(record: Record<string, unknown>): SapProviderConfig | undefined {
const sap = record.sap
if (!isPlainRecord(sap)) {
return undefined
}
const result: SapProviderConfig = {
clientId: readString(sap, "clientId"),
clientSecret: readString(sap, "clientSecret"),
tokenUrl: readString(sap, "tokenUrl"),
resourceGroup: readString(sap, "resourceGroup"),
deploymentId: readString(sap, "deploymentId"),
useOrchestrationMode: readBoolean(sap, "useOrchestrationMode"),
api: readString(sap, "api"),
defaultSettings: isPlainRecord(sap.defaultSettings) ? sap.defaultSettings : undefined,
}
return Object.values(result).some((value) => value !== undefined) ? result : undefined
}
function readOca(record: Record<string, unknown>): OcaProviderConfig | undefined {
const oca = record.oca
if (!isPlainRecord(oca)) {
return undefined
}
const result: OcaProviderConfig = {
mode: readString(oca, "mode"),
usePromptCache: readBoolean(oca, "usePromptCache"),
}
return Object.values(result).some((value) => value !== undefined) ? result : undefined
}
function readAws(record: Record<string, unknown>): AwsProviderConfig | undefined {
const aws = record.aws
if (!isPlainRecord(aws)) {
@@ -184,6 +246,7 @@ function readAws(record: Record<string, unknown>): AwsProviderConfig | undefined
accessKey: readString(aws, "accessKey"),
secretKey: readString(aws, "secretKey"),
sessionToken: readString(aws, "sessionToken"),
region: readString(aws, "region"),
authentication: readString(aws, "authentication"),
profile: readString(aws, "profile"),
usePromptCache: readBoolean(aws, "usePromptCache"),
@@ -195,26 +258,37 @@ function readAws(record: Record<string, unknown>): AwsProviderConfig | undefined
return Object.values(result).some((value) => value !== undefined) ? result : undefined
}
function readProviderSettings(providerId: ProviderId): ConfigParts {
function readProviderSettings(providerId: ProviderId): ProviderSettingsRead {
try {
const settings: unknown = getProviderSettingsManager().getProviderSettings(providerId)
const manager = getProviderSettingsManager()
const settings: unknown =
manager.getProviderSettings(toSdkProviderId(providerId)) ?? manager.getProviderSettings(providerId)
if (!isPlainRecord(settings)) {
return {}
return { exists: false, config: {} }
}
const aws = readAws(settings)
const gcp = readGcp(settings)
const azure = readAzure(settings)
return {
apiKey: readString(settings, "apiKey"),
baseUrl: readString(settings, "baseUrl"),
apiLine: readString(settings, "apiLine"),
headers: readHeaders(settings, "headers"),
region: readString(settings, "region"),
aws: readAws(settings),
gcp: readGcp(settings),
auth: readAuth(settings),
extras: isPlainRecord(settings.extras) ? settings.extras : undefined,
} satisfies ProviderSettingsLike
exists: true,
config: {
apiKey: readString(settings, "apiKey"),
baseUrl: readString(settings, "baseUrl"),
apiLine: readString(settings, "apiLine"),
headers: readHeaders(settings, "headers"),
region: readString(settings, "region") ?? aws?.region ?? gcp?.region,
aws,
gcp,
azure,
sap: readSap(settings),
oca: readOca(settings),
auth: readAuth(settings),
extras: isPlainRecord(settings.extras) ? settings.extras : undefined,
} satisfies ProviderSettingsLike,
}
} catch {
return {}
return { exists: false, config: {} }
}
}
@@ -283,6 +357,17 @@ function readStateGcp(provider: string, config: ApiConfiguration): GcpProviderCo
return Object.values(gcp).some((value) => value !== undefined) ? gcp : undefined
}
function readStateAzure(provider: string, config: ApiConfiguration): AzureProviderConfig | undefined {
if (provider !== "openai") {
return undefined
}
const azure: AzureProviderConfig = {
apiVersion: readStringFromConfig(config, "azureApiVersion"),
}
return Object.values(azure).some((value) => value !== undefined) ? azure : undefined
}
function readStateAws(provider: string, config: ApiConfiguration): AwsProviderConfig | undefined {
if (provider !== "bedrock") {
return undefined
@@ -292,6 +377,7 @@ function readStateAws(provider: string, config: ApiConfiguration): AwsProviderCo
accessKey: readStringFromConfig(config, "awsAccessKey"),
secretKey: readStringFromConfig(config, "awsSecretKey"),
sessionToken: readStringFromConfig(config, "awsSessionToken"),
region: readStringFromConfig(config, "awsRegion"),
authentication: readStringFromConfig(config, "awsAuthentication"),
profile: readStringFromConfig(config, "awsProfile"),
usePromptCache: readStateBoolean(config, "awsBedrockUsePromptCache"),
@@ -302,8 +388,35 @@ function readStateAws(provider: string, config: ApiConfiguration): AwsProviderCo
return Object.values(aws).some((value) => value !== undefined) ? aws : undefined
}
function readStateSap(provider: string, config: ApiConfiguration): SapProviderConfig | undefined {
if (provider !== "sapaicore") {
return undefined
}
const sap: SapProviderConfig = {
clientId: readStringFromConfig(config, "sapAiCoreClientId"),
clientSecret: readStringFromConfig(config, "sapAiCoreClientSecret"),
tokenUrl: readStringFromConfig(config, "sapAiCoreTokenUrl"),
resourceGroup: readStringFromConfig(config, "sapAiResourceGroup"),
useOrchestrationMode: readStateBoolean(config, "sapAiCoreUseOrchestrationMode"),
}
return Object.values(sap).some((value) => value !== undefined) ? sap : undefined
}
function readStateOca(provider: string, config: ApiConfiguration): OcaProviderConfig | undefined {
if (provider !== "oca") {
return undefined
}
const mode = readStringFromConfig(config, "ocaMode")
const oca: OcaProviderConfig = {
mode,
}
return Object.values(oca).some((value) => value !== undefined) ? oca : undefined
}
function readStateConfig(providerId: ProviderId, config: ApiConfiguration): ConfigParts {
const provider = providerId.toString()
const provider = toLegacyApiProvider(providerId.toString())
return {
apiKey: readStringFromConfig(config, apiKeyFields[provider]),
baseUrl: readStringFromConfig(config, baseUrlFields[provider]),
@@ -312,6 +425,9 @@ function readStateConfig(providerId: ProviderId, config: ApiConfiguration): Conf
region: readStringFromConfig(config, regionFields[provider]),
aws: readStateAws(provider, config),
gcp: readStateGcp(provider, config),
azure: readStateAzure(provider, config),
sap: readStateSap(provider, config),
oca: readStateOca(provider, config),
auth: readStateAuth(provider, config),
extras: readStateExtras(provider, config),
}
@@ -337,14 +453,39 @@ function mergeGcp(first: GcpProviderConfig | undefined, second: GcpProviderConfi
return { ...first, ...second }
}
function mergeAws(first: AwsProviderConfig | undefined, second: AwsProviderConfig | undefined): AwsProviderConfig | undefined {
function mergeAzure(
first: AzureProviderConfig | undefined,
second: AzureProviderConfig | undefined,
): AzureProviderConfig | undefined {
return mergeDefined(first, second)
}
function mergeDefined<T extends object>(first: T | undefined, second: T | undefined): T | undefined {
if (!first) {
return second
}
if (!second) {
return first
}
return { ...first, ...second }
const merged: Record<string, unknown> = { ...(first as Record<string, unknown>) }
for (const [key, value] of Object.entries(second)) {
if (value !== undefined) {
merged[key] = value
}
}
return merged as T
}
function mergeSap(first: SapProviderConfig | undefined, second: SapProviderConfig | undefined): SapProviderConfig | undefined {
return mergeDefined(first, second)
}
function mergeOca(first: OcaProviderConfig | undefined, second: OcaProviderConfig | undefined): OcaProviderConfig | undefined {
return mergeDefined(first, second)
}
function mergeAws(first: AwsProviderConfig | undefined, second: AwsProviderConfig | undefined): AwsProviderConfig | undefined {
return mergeDefined(first, second)
}
function assignIfDefined<T extends ConfigKey>(target: Partial<ConfigParts>, key: T, value: ConfigParts[T] | undefined): void {
@@ -354,30 +495,45 @@ function assignIfDefined<T extends ConfigKey>(target: Partial<ConfigParts>, key:
}
/**
* Build an {@link EffectiveProviderConfig} by merging provider-owned settings
* from SDK `providers.json` with the current StateManager effective API
* configuration. StateManager's `getApiConfiguration()` already applies
* task/session/remote-config overlays for legacy fields, so those values win.
* Build an {@link EffectiveProviderConfig} from SDK `providers.json`.
* Legacy StateManager config is a migration fallback for users that do not
* have an SDK provider settings record yet; it is not a live shadow source
* after SDK settings exist.
*
* Mode-dependent model selection is intentionally excluded; callers use
* `ProviderConfigStore.readSelection(providerId, mode)` for that.
*/
export function buildEffectiveProviderConfig(providerId: ProviderId): EffectiveProviderConfig {
const providerSettings = readProviderSettings(providerId)
const providerSettingsRead = readProviderSettings(providerId)
const providerSettings = providerSettingsRead.config
const stateConfig = readStateConfig(providerId, StateManager.get().getApiConfiguration())
const fallbackConfig: ConfigParts = providerSettingsRead.exists ? {} : stateConfig
const merged: Partial<ConfigParts> = {}
assignIfDefined(merged, "apiKey", stateConfig.apiKey ?? providerSettings.apiKey)
assignIfDefined(merged, "baseUrl", stateConfig.baseUrl ?? providerSettings.baseUrl)
assignIfDefined(merged, "apiLine", stateConfig.apiLine ?? providerSettings.apiLine)
assignIfDefined(merged, "headers", stateConfig.headers ?? providerSettings.headers)
assignIfDefined(merged, "region", stateConfig.region ?? providerSettings.region)
// Bedrock/Vertex are migrated to providers.json. Keep legacy StateManager cloud
// fields as a fallback for old installs, but let providers.json win when both exist.
assignIfDefined(merged, "aws", mergeAws(stateConfig.aws, providerSettings.aws))
assignIfDefined(merged, "gcp", mergeGcp(stateConfig.gcp, providerSettings.gcp))
assignIfDefined(merged, "auth", stateConfig.auth ?? providerSettings.auth)
assignIfDefined(merged, "extras", mergeExtras(providerSettings.extras, stateConfig.extras))
const remoteConfig = buildRemoteProviderConfig(providerId)
assignIfDefined(merged, "apiKey", remoteConfig.apiKey ?? providerSettings.apiKey ?? fallbackConfig.apiKey)
assignIfDefined(merged, "baseUrl", remoteConfig.baseUrl ?? providerSettings.baseUrl ?? fallbackConfig.baseUrl)
assignIfDefined(merged, "apiLine", remoteConfig.apiLine ?? providerSettings.apiLine ?? fallbackConfig.apiLine)
assignIfDefined(merged, "headers", remoteConfig.headers ?? providerSettings.headers ?? fallbackConfig.headers)
assignIfDefined(merged, "region", remoteConfig.region ?? providerSettings.region ?? fallbackConfig.region)
assignIfDefined(merged, "aws", mergeAws(mergeAws(fallbackConfig.aws, providerSettings.aws), remoteConfig.aws))
assignIfDefined(merged, "gcp", mergeGcp(mergeGcp(fallbackConfig.gcp, providerSettings.gcp), remoteConfig.gcp))
assignIfDefined(merged, "azure", mergeAzure(mergeAzure(fallbackConfig.azure, providerSettings.azure), remoteConfig.azure))
assignIfDefined(merged, "sap", mergeSap(mergeSap(fallbackConfig.sap, providerSettings.sap), remoteConfig.sap))
assignIfDefined(merged, "oca", mergeOca(mergeOca(fallbackConfig.oca, providerSettings.oca), remoteConfig.oca))
assignIfDefined(merged, "auth", providerSettings.auth ?? fallbackConfig.auth)
assignIfDefined(merged, "extras", mergeExtras(fallbackConfig.extras, providerSettings.extras))
return { providerId, ...merged }
}
export function buildRemoteProviderConfig(providerId: ProviderId): ConfigParts {
try {
const manager = StateManager.get() as { getRemoteConfigSettings?: () => unknown }
const remoteConfigSettings = manager.getRemoteConfigSettings?.()
return isPlainRecord(remoteConfigSettings) ? readStateConfig(providerId, remoteConfigSettings as ApiConfiguration) : {}
} catch {
return {}
}
}
@@ -112,6 +112,12 @@ describe("computeConfigFingerprint", () => {
)
})
it("changes when Azure OpenAI-compatible settings change", () => {
expect(computeConfigFingerprint(providerId, makeConfig({ azure: { apiVersion: "2025-01-01-preview" } }))).not.toBe(
computeConfigFingerprint(providerId, makeConfig({ azure: { apiVersion: "2024-02-15-preview" } })),
)
})
it("does not include raw secret sentinel values in the returned fingerprint", () => {
const fingerprint = computeConfigFingerprint(
providerId,
@@ -106,6 +106,15 @@ function sanitizeGcp(config: EffectiveProviderConfig["gcp"]): Readonly<Record<st
}
}
function sanitizeAzure(config: EffectiveProviderConfig["azure"]): Readonly<Record<string, unknown>> | null {
if (!config) {
return null
}
return {
apiVersion: config.apiVersion ?? null,
}
}
function sanitizeAws(config: EffectiveProviderConfig["aws"]): Readonly<Record<string, unknown>> | null {
if (!config) {
return null
@@ -114,6 +123,7 @@ function sanitizeAws(config: EffectiveProviderConfig["aws"]): Readonly<Record<st
accessKey: shortSecretHash(config.accessKey),
secretKey: shortSecretHash(config.secretKey),
sessionToken: shortSecretHash(config.sessionToken),
region: config.region ?? null,
authentication: config.authentication ?? null,
profile: config.profile ?? null,
usePromptCache: config.usePromptCache ?? null,
@@ -124,6 +134,32 @@ function sanitizeAws(config: EffectiveProviderConfig["aws"]): Readonly<Record<st
}
}
function sanitizeSap(config: EffectiveProviderConfig["sap"]): Readonly<Record<string, unknown>> | null {
if (!config) {
return null
}
return {
clientId: shortSecretHash(config.clientId),
clientSecret: shortSecretHash(config.clientSecret),
tokenUrl: config.tokenUrl ?? null,
resourceGroup: config.resourceGroup ?? null,
deploymentId: config.deploymentId ?? null,
useOrchestrationMode: config.useOrchestrationMode ?? null,
api: config.api ?? null,
defaultSettings: sanitizeExtras(config.defaultSettings),
}
}
function sanitizeOca(config: EffectiveProviderConfig["oca"]): Readonly<Record<string, unknown>> | null {
if (!config) {
return null
}
return {
mode: config.mode ?? null,
usePromptCache: config.usePromptCache ?? null,
}
}
/**
* Canonical JSON serialization with deterministic key ordering at every
* object depth. Arrays preserve order because array order is meaningful.
@@ -170,6 +206,9 @@ export function computeConfigFingerprint(providerId: ProviderId, config: Effecti
region: config.region ?? null,
aws: sanitizeAws(config.aws),
gcp: sanitizeGcp(config.gcp),
azure: sanitizeAzure(config.azure),
sap: sanitizeSap(config.sap),
oca: sanitizeOca(config.oca),
extras: sanitizeExtras(config.extras),
auth: {
accountId: config.auth?.accountId ?? null,
@@ -45,10 +45,6 @@ describe("parseProviderId", () => {
parseProviderId("anthropic")
parseProviderId("openai")
parseProviderId("nousResearch")
parseProviderId("zai-coding-plan")
parseProviderId("poolside")
parseProviderId("v0")
parseProviderId("xiaomi")
expect(warnSpy).not.toHaveBeenCalled()
})
@@ -60,10 +56,6 @@ describe("isKnownProviderId", () => {
expect(isKnownProviderId(parseProviderId("openai"))).toBe(true)
expect(isKnownProviderId(parseProviderId("deepseek"))).toBe(true)
expect(isKnownProviderId(parseProviderId("nousResearch"))).toBe(true)
expect(isKnownProviderId(parseProviderId("zai-coding-plan"))).toBe(true)
expect(isKnownProviderId(parseProviderId("poolside"))).toBe(true)
expect(isKnownProviderId(parseProviderId("v0"))).toBe(true)
expect(isKnownProviderId(parseProviderId("xiaomi"))).toBe(true)
})
it("returns false for a custom provider id", () => {
@@ -41,22 +41,18 @@ const KNOWN_API_PROVIDERS = {
cerebras: true,
sapaicore: true,
groq: true,
poolside: true,
huggingface: true,
"huawei-cloud-maas": true,
dify: true,
baseten: true,
"vercel-ai-gateway": true,
v0: true,
zai: true,
"zai-coding-plan": true,
oca: true,
aihubmix: true,
minimax: true,
hicap: true,
nousResearch: true,
wandb: true,
xiaomi: true,
"cline-pass": true,
} satisfies Record<ApiProvider, true>
@@ -1,4 +1,5 @@
import { openAiModelInfoSafeDefaults } from "@shared/api"
import { ApiFormat } from "@shared/proto/cline/models"
import { describe, expect, it } from "vitest"
import { adaptSdkModelInfo, CatalogShapeError } from "./shape-adapter"
@@ -23,6 +24,9 @@ describe("adaptSdkModelInfo", () => {
expect(() => adaptSdkModelInfo({ id: "m", maxTokens: "huge" })).toThrow(CatalogShapeError)
expect(() => adaptSdkModelInfo({ id: "m", name: 1 })).toThrow(CatalogShapeError)
expect(() => adaptSdkModelInfo({ id: "m", description: 1 })).toThrow(CatalogShapeError)
expect(() => adaptSdkModelInfo({ id: "m", apiFormat: "messages" })).toThrow(CatalogShapeError)
expect(() => adaptSdkModelInfo({ id: "m", temperature: "0" })).toThrow(CatalogShapeError)
expect(() => adaptSdkModelInfo({ id: "m", thinkingConfig: "enabled" })).toThrow(CatalogShapeError)
})
it("throws CatalogShapeError when capabilities is malformed", () => {
@@ -37,6 +41,11 @@ describe("adaptSdkModelInfo", () => {
expect(() => adaptSdkModelInfo({ id: "m", pricing: { input: Number.POSITIVE_INFINITY } })).toThrow(CatalogShapeError)
})
it("throws CatalogShapeError when metadata is malformed", () => {
expect(() => adaptSdkModelInfo({ id: "m", metadata: "sap" })).toThrow(CatalogShapeError)
expect(() => adaptSdkModelInfo({ id: "m", metadata: [] })).toThrow(CatalogShapeError)
})
it("CatalogShapeError exposes useful message and details", () => {
try {
adaptSdkModelInfo({ id: 5 })
@@ -149,9 +158,13 @@ describe("adaptSdkModelInfo", () => {
maxTokens: 8192,
capabilities: ["tools", "reasoning", "structured_output", "temperature", "prompt-cache", "images"],
pricing: { input: 0.5, output: 1.5, cacheRead: 0.05, cacheWrite: 0.1 },
apiFormat: "openai-responses",
temperature: 0,
thinkingConfig: { maxBudget: 4096 },
releaseDate: "2026-04-01",
family: "deepseek",
status: "ga",
metadata: { sap: { deploymentId: "deployment-123" } },
})
expect(model).toMatchObject({
@@ -165,6 +178,12 @@ describe("adaptSdkModelInfo", () => {
outputPrice: 1.5,
cacheReadsPrice: 0.05,
cacheWritesPrice: 0.1,
apiFormat: ApiFormat.OPENAI_RESPONSES,
temperature: 0,
thinkingConfig: { maxBudget: 4096 },
})
expect((model as typeof model & { metadata?: Record<string, unknown> }).metadata).toEqual({
sap: { deploymentId: "deployment-123" },
})
expect(Object.hasOwn(model, "releaseDate")).toBe(false)
expect(Object.hasOwn(model, "family")).toBe(false)
@@ -15,6 +15,9 @@
* capabilities?: string[], // e.g. ["tools", "reasoning", "prompt-cache", "images"]
* pricing?: { input?, output?, cacheRead?, cacheWrite? },
* description?: string,
* apiFormat?: "default" | "openai-responses" | "r1",
* temperature?: number,
* thinkingConfig?: object,
* releaseDate?: string, // not mapped — see "Unmapped SDK fields" below
* family?: string, // not mapped
* status?: string, // not mapped
@@ -37,19 +40,23 @@
* | cacheReadsPrice | `sdk.pricing.cacheRead` if finite number | omitted (undefined) |
* | cacheWritesPrice | `sdk.pricing.cacheWrite` if finite number | omitted (undefined) |
* | description | `sdk.description` if string | omitted (undefined) |
* | apiFormat | SDK `apiFormat` mapped to extension enum | omitted for `default`/missing |
* | temperature | `sdk.temperature` if finite number | omitted (undefined) |
* | thinkingConfig | `sdk.thinkingConfig` if object | omitted (undefined) |
* | metadata | `sdk.metadata` if object | omitted (undefined) |
*
* Unmapped SDK fields intentionally dropped here: `releaseDate`, `family`,
* `status`, and capabilities other than `images`/`vision`/`prompt-cache`/
* `reasoning` (for example `tools`, `streaming`, `structured_output`,
* `temperature`).
*
* Extension-only fields not populated by this adapter: `thinkingConfig`,
* `tiers`, `temperature`, `apiFormat`, `supportsGlobalEndpoint`, and local
* provider loaded-context overrides. Those require host enrichment or upstream
* SDK metadata rather than adapter guesses.
* Extension-only fields not populated by this adapter: `tiers`,
* `supportsGlobalEndpoint`, and local provider loaded-context overrides.
* Those require host enrichment or upstream SDK metadata rather than adapter guesses.
*/
import { type ModelInfo, openAiModelInfoSafeDefaults } from "@shared/api"
import { ApiFormat } from "@shared/proto/cline/models"
/**
* Typed error thrown when SDK model-info shape validation fails. The catalog
@@ -136,6 +143,21 @@ function readPricing(value: unknown): NormalizedPricing | undefined {
return result
}
function readApiFormat(value: unknown): ApiFormat | undefined {
if (value === undefined || value === "default") {
return undefined
}
if (value === "openai-responses") {
return ApiFormat.OPENAI_RESPONSES
}
if (value === "r1") {
return ApiFormat.R1_CHAT
}
throw new CatalogShapeError("SDK model-info `apiFormat` must be `default`, `openai-responses`, or `r1` when present.", {
details: { receivedValue: value },
})
}
/**
* Adapt an SDK model-info shape into the extension's {@link ModelInfo} shape.
*
@@ -190,6 +212,19 @@ export function adaptSdkModelInfo(input: unknown): ModelInfo {
const capabilities = readStringArray(input.capabilities)
const pricing = readPricing(input.pricing)
const apiFormat = readApiFormat(input.apiFormat)
const rawTemperature = input.temperature
if (rawTemperature !== undefined && !isFiniteNumber(rawTemperature)) {
throw new CatalogShapeError("SDK model-info `temperature` must be a finite number when present.", {
details: { receivedType: typeof rawTemperature },
})
}
const thinkingConfig = input.thinkingConfig
if (thinkingConfig !== undefined && !isPlainObject(thinkingConfig)) {
throw new CatalogShapeError("SDK model-info `thinkingConfig` must be an object when present.", {
details: { receivedType: typeof thinkingConfig },
})
}
const result: ModelInfo = {
name: rawName ?? id,
@@ -220,6 +255,23 @@ export function adaptSdkModelInfo(input: unknown): ModelInfo {
if (rawDescription !== undefined) {
result.description = rawDescription
}
if (apiFormat !== undefined) {
result.apiFormat = apiFormat
}
if (rawTemperature !== undefined) {
result.temperature = rawTemperature
}
if (thinkingConfig !== undefined) {
result.thinkingConfig = { ...thinkingConfig }
}
if (input.metadata !== undefined) {
if (!isPlainObject(input.metadata)) {
throw new CatalogShapeError("SDK model-info `metadata` must be an object when present.", {
details: { receivedType: typeof input.metadata },
})
}
;(result as ModelInfo & { metadata?: Record<string, unknown> }).metadata = { ...input.metadata }
}
return result
}
+179 -36
View File
@@ -4,7 +4,7 @@ import type { ProviderConfigChange } from "./contracts"
import { parseProviderId } from "./provider-id"
const mocks = vi.hoisted(() => {
type MockApiConfiguration = ApiConfiguration & { planActSeparateModelsSetting?: boolean }
type MockApiConfiguration = ApiConfiguration
let apiConfiguration: MockApiConfiguration = {}
let providerSettingsById: Record<string, Record<string, unknown>> = {}
const saveProviderSettings = vi.fn((settings: Record<string, unknown>, _options?: { setLastUsed?: boolean }) => {
@@ -31,9 +31,6 @@ const mocks = vi.hoisted(() => {
getSavedProviderSettings(providerId: string): Record<string, unknown> | undefined {
return providerSettingsById[providerId]
},
getApiConfiguration(): MockApiConfiguration {
return { ...apiConfiguration }
},
getSaveProviderSettingsMock(): typeof saveProviderSettings {
return saveProviderSettings
},
@@ -117,6 +114,174 @@ describe("createProviderConfigStore", () => {
expect(store.read(providerId).baseUrl).toBeUndefined()
})
it("writes generic Bedrock SDK settings without mirroring legacy provider keys", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("bedrock")
store.write(providerId, {
settings: {
apiKey: "bedrock-api-key",
aws: {
authentication: "api-key",
region: "us-west-2",
accessKey: "access-key",
secretKey: "secret-key",
sessionToken: "session-token",
endpoint: "https://bedrock.example",
customModelBaseId: "base-profile",
useCrossRegionInference: true,
useGlobalInference: false,
usePromptCache: true,
},
},
apiKey: "bedrock-api-key",
aws: {
authentication: "api-key",
region: "us-west-2",
accessKey: "access-key",
secretKey: "secret-key",
sessionToken: "session-token",
endpoint: "https://bedrock.example",
customModelBaseId: "base-profile",
useCrossRegionInference: true,
useGlobalInference: false,
usePromptCache: true,
},
})
expect(mocks.getSavedProviderSettings("bedrock")).toMatchObject({
provider: "bedrock",
apiKey: "bedrock-api-key",
aws: {
authentication: "api-key",
region: "us-west-2",
customModelBaseId: "base-profile",
},
})
expect(mocks.getStateManager().getApiConfiguration()).toEqual({})
})
it("stores formerly mode-scoped Bedrock and SAP SDK fields as provider settings", async () => {
const { createProviderConfigStore } = await import("./store")
mocks.setApiConfiguration({
planModeAwsBedrockCustomModelBaseId: "old-plan-base",
actModeAwsBedrockCustomModelBaseId: "old-act-base",
planModeSapAiCoreDeploymentId: "old-plan-deployment",
actModeSapAiCoreDeploymentId: "old-act-deployment",
})
mocks.setProviderSettings({
bedrock: { provider: "bedrock", aws: { customModelBaseId: "provider-wide-base" } },
sapaicore: { provider: "sapaicore", sap: { deploymentId: "provider-wide-deployment" } },
})
const store = createProviderConfigStore()
store.write(parseProviderId("bedrock"), {
mode: "plan",
settings: { aws: { customModelBaseId: "new-plan-base" } },
aws: { customModelBaseId: "new-plan-base" },
})
store.write(parseProviderId("sapaicore"), {
mode: "act",
settings: { sap: { deploymentId: "new-act-deployment" } },
sap: { deploymentId: "new-act-deployment" },
})
expect(mocks.getStateManager().getApiConfiguration()).toMatchObject({
planModeAwsBedrockCustomModelBaseId: "old-plan-base",
actModeAwsBedrockCustomModelBaseId: "old-act-base",
planModeSapAiCoreDeploymentId: "old-plan-deployment",
actModeSapAiCoreDeploymentId: "old-act-deployment",
})
expect(mocks.getSavedProviderSettings("bedrock")).toMatchObject({
provider: "bedrock",
aws: { customModelBaseId: "new-plan-base" },
})
expect(mocks.getSavedProviderSettings("sapaicore")).toMatchObject({
provider: "sapaicore",
sap: { deploymentId: "new-act-deployment" },
})
})
it("writes OpenAI-compatible SDK settings without mirroring legacy OpenAI keys", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
store.write(parseProviderId("openai-compatible"), {
settings: {
apiKey: "openai-compatible-key",
baseUrl: "https://compatible.example/v1",
headers: { "x-provider": "compatible" },
azure: { apiVersion: "2025-01-01-preview" },
},
apiKey: "openai-compatible-key",
baseUrl: "https://compatible.example/v1",
headers: { "x-provider": "compatible" },
azure: { apiVersion: "2025-01-01-preview" },
})
expect(mocks.getSavedProviderSettings("openai-compatible")).toMatchObject({
provider: "openai-compatible",
apiKey: "openai-compatible-key",
baseUrl: "https://compatible.example/v1",
headers: { "x-provider": "compatible" },
azure: { apiVersion: "2025-01-01-preview" },
})
expect(mocks.getStateManager().getApiConfiguration()).toEqual({})
})
it("writes generic SAP and OCA SDK settings without mirroring legacy provider keys", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
store.write(parseProviderId("sapaicore"), {
settings: {
baseUrl: "https://sap.example",
sap: {
clientId: "sap-client",
clientSecret: "sap-secret",
tokenUrl: "https://auth.sap.example",
resourceGroup: "sap-group",
deploymentId: "sap-deployment",
useOrchestrationMode: true,
},
},
baseUrl: "https://sap.example",
sap: {
clientId: "sap-client",
clientSecret: "sap-secret",
tokenUrl: "https://auth.sap.example",
resourceGroup: "sap-group",
deploymentId: "sap-deployment",
useOrchestrationMode: true,
},
})
store.write(parseProviderId("oca"), {
settings: { baseUrl: "https://oca.example", oca: { mode: "external", usePromptCache: true } },
baseUrl: "https://oca.example",
oca: { mode: "external", usePromptCache: true },
})
expect(mocks.getSavedProviderSettings("sapaicore")).toMatchObject({
provider: "sapaicore",
baseUrl: "https://sap.example",
sap: {
clientId: "sap-client",
clientSecret: "sap-secret",
tokenUrl: "https://auth.sap.example",
resourceGroup: "sap-group",
deploymentId: "sap-deployment",
useOrchestrationMode: true,
},
})
expect(mocks.getSavedProviderSettings("oca")).toMatchObject({
provider: "oca",
baseUrl: "https://oca.example",
oca: { mode: "external", usePromptCache: true },
})
expect(mocks.getStateManager().getApiConfiguration()).toEqual({})
})
it("round-trips commitSelection then readSelection for provider-specific model info", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
@@ -181,29 +346,13 @@ describe("createProviderConfigStore", () => {
expect(written).toEqual({ providerId, apiKey: "nous-key" })
expect(store.readSelection(providerId, "act")).toEqual(selection)
expect(mocks.getSavedProviderSettings("nousresearch")).toMatchObject({
provider: "nousresearch",
expect(mocks.getSavedProviderSettings("nousResearch")).toMatchObject({
provider: "nousResearch",
apiKey: "nous-key",
model: "nousresearch/hermes-4-70b",
})
})
it("writes Z.AI Coding Plan API keys only to provider-specific settings", async () => {
const { createProviderConfigStore } = await import("./store")
mocks.setApiConfiguration({ zaiApiKey: "shared-zai-key" })
const store = createProviderConfigStore()
const providerId = parseProviderId("zai-coding-plan")
const written = store.write(providerId, { apiKey: "coding-plan-key" })
expect(written).toEqual({ providerId, apiKey: "coding-plan-key" })
expect(mocks.getSavedProviderSettings("zai-coding-plan")).toMatchObject({
provider: "zai-coding-plan",
apiKey: "coding-plan-key",
})
expect(mocks.getApiConfiguration().zaiApiKey).toBe("shared-zai-key")
})
it("returns undefined from readSelection when modelId or modelInfo is missing", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
@@ -216,9 +365,8 @@ describe("createProviderConfigStore", () => {
expect(store.readSelection(providerId, "act")).toBeUndefined()
})
it("keeps Plan and Act selections independent and mirrors the latest selection to provider settings", async () => {
it("uses the latest provider setting selection for both modes", async () => {
const { createProviderConfigStore } = await import("./store")
mocks.setApiConfiguration({ planActSeparateModelsSetting: true })
const store = createProviderConfigStore()
const providerId = parseProviderId("openrouter")
const planSelection = { providerId, modelId: "provider/model-a", modelInfo: modelInfoA }
@@ -227,22 +375,19 @@ describe("createProviderConfigStore", () => {
store.commitSelection(providerId, "plan", planSelection)
store.commitSelection(providerId, "act", actSelection)
expect(store.readSelection(providerId, "plan")).toEqual(planSelection)
expect(store.readSelection(providerId, "plan")).toEqual(actSelection)
expect(store.readSelection(providerId, "act")).toEqual(actSelection)
expect(mocks.getSavedProviderSettings("openrouter")).toMatchObject({
provider: "openrouter",
model: "provider/model-b",
contextWindow: 64_000,
maxTokens: 4_096,
})
expect(mocks.getSavedProviderSettings("openrouter")).not.toHaveProperty("contextWindow")
expect(mocks.getSavedProviderSettings("openrouter")).not.toHaveProperty("maxTokens")
})
it("updates providers.json model with setLastUsed false when planActSeparateModelsSetting=false", async () => {
it("updates providers.json model with setLastUsed false", async () => {
const { createProviderConfigStore } = await import("./store")
mocks.setApiConfiguration({ planActSeparateModelsSetting: false })
mocks.setProviderSettings({
openrouter: { provider: "openrouter", apiKey: "existing-key", contextWindow: 64_000, maxTokens: 4_096 },
})
mocks.setProviderSettings({ openrouter: { provider: "openrouter", apiKey: "existing-key" } })
const store = createProviderConfigStore()
const providerId = parseProviderId("openrouter")
const selection = { providerId, modelId: "provider/model-a", modelInfo: modelInfoA }
@@ -254,8 +399,6 @@ describe("createProviderConfigStore", () => {
apiKey: "existing-key",
model: "provider/model-a",
})
expect(mocks.getSavedProviderSettings("openrouter")).not.toHaveProperty("contextWindow")
expect(mocks.getSavedProviderSettings("openrouter")).not.toHaveProperty("maxTokens")
expect(mocks.getSaveProviderSettingsMock()).toHaveBeenCalledWith(expect.objectContaining({ model: "provider/model-a" }), {
setLastUsed: false,
})
@@ -272,9 +415,9 @@ describe("createProviderConfigStore", () => {
expect(mocks.getSavedProviderSettings("claude-code")).toMatchObject({
provider: "claude-code",
model: "haiku",
contextWindow: 128_000,
maxTokens: 8_192,
})
expect(mocks.getSavedProviderSettings("claude-code")).not.toHaveProperty("contextWindow")
expect(mocks.getSavedProviderSettings("claude-code")).not.toHaveProperty("maxTokens")
expect(mocks.getSaveProviderSettingsMock()).toHaveBeenCalledWith(expect.objectContaining({ model: "haiku" }), {
setLastUsed: false,
})
+141 -200
View File
@@ -1,8 +1,9 @@
import { getGeneratedModelsForProvider, MODEL_COLLECTIONS_BY_PROVIDER_ID } from "@cline/llms"
import { type ApiConfiguration, type ApiProvider, type ModelInfo, openAiModelInfoSafeDefaults } from "@shared/api"
import { getProviderModelIdKey } from "@shared/storage/provider-keys"
import { isSecretKey, isSettingsKey, type SecretKey, type SettingsKey } from "@shared/storage/state-keys"
import type { SettingsKey } from "@shared/storage/state-keys"
import { StateManager } from "@/core/storage/StateManager"
import { toLegacyApiProvider } from "@/shared/model-catalog/provider-helpers"
import { getProviderSettingsManager } from "../provider-migration"
import type {
Disposable,
@@ -21,76 +22,11 @@ import { toSdkProviderId } from "./sdk-provider-id"
import { adaptSdkModelInfo } from "./shape-adapter"
type ProviderSettingsRecord = Record<string, unknown>
type ProviderSettingsPatchKey = "apiKey" | "baseUrl" | "apiLine" | "headers" | "region" | "auth" | "extras" | "aws" | "gcp"
type ModelInfoKeys = {
readonly plan: keyof ApiConfiguration & SettingsKey
readonly act: keyof ApiConfiguration & SettingsKey
}
const providerConfigStateKeys: Record<ProviderSettingsPatchKey, Partial<Record<string, SecretKey | SettingsKey>>> = {
apiKey: {
anthropic: "apiKey",
openrouter: "openRouterApiKey",
openai: "openAiApiKey",
"openai-native": "openAiNativeApiKey",
"openai-codex": "openAiNativeApiKey",
bedrock: "awsBedrockApiKey",
gemini: "geminiApiKey",
deepseek: "deepSeekApiKey",
ollama: "ollamaApiKey",
requesty: "requestyApiKey",
together: "togetherApiKey",
fireworks: "fireworksApiKey",
qwen: "qwenApiKey",
"qwen-code": "qwenApiKey",
doubao: "doubaoApiKey",
mistral: "mistralApiKey",
litellm: "liteLlmApiKey",
asksage: "asksageApiKey",
xai: "xaiApiKey",
moonshot: "moonshotApiKey",
zai: "zaiApiKey",
huggingface: "huggingFaceApiKey",
nebius: "nebiusApiKey",
sambanova: "sambanovaApiKey",
cerebras: "cerebrasApiKey",
groq: "groqApiKey",
baseten: "basetenApiKey",
"huawei-cloud-maas": "huaweiCloudMaasApiKey",
dify: "difyApiKey",
minimax: "minimaxApiKey",
hicap: "hicapApiKey",
aihubmix: "aihubmixApiKey",
nousresearch: "nousResearchApiKey",
"vercel-ai-gateway": "vercelAiGatewayApiKey",
wandb: "wandbApiKey",
oca: "ocaApiKey",
cline: "clineApiKey",
},
baseUrl: {
anthropic: "anthropicBaseUrl",
openai: "openAiBaseUrl",
ollama: "ollamaBaseUrl",
lmstudio: "lmStudioBaseUrl",
gemini: "geminiBaseUrl",
requesty: "requestyBaseUrl",
asksage: "asksageApiUrl",
litellm: "liteLlmBaseUrl",
sapaicore: "sapAiCoreBaseUrl",
dify: "difyBaseUrl",
oca: "ocaBaseUrl",
aihubmix: "aihubmixBaseUrl",
},
apiLine: { qwen: "qwenApiLine", moonshot: "moonshotApiLine", zai: "zaiApiLine", minimax: "minimaxApiLine" },
headers: { openai: "openAiHeaders" },
region: { bedrock: "awsRegion", vertex: "vertexRegion" },
auth: {},
extras: {},
aws: {},
gcp: {},
}
const modelInfoKeysByProvider: Partial<Record<string, ModelInfoKeys>> = {
openrouter: { plan: "planModeOpenRouterModelInfo", act: "actModeOpenRouterModelInfo" },
cline: { plan: "planModeClineModelInfo", act: "actModeClineModelInfo" },
@@ -107,28 +43,13 @@ const modelInfoKeysByProvider: Partial<Record<string, ModelInfoKeys>> = {
"vercel-ai-gateway": { plan: "planModeVercelAiGatewayModelInfo", act: "actModeVercelAiGatewayModelInfo" },
}
// In-memory selection envelope for providers that have a mode-specific model
// id key but no durable `*ModelInfo` key in the StateManager schema (for
// example DeepSeek/Gemini/generic SDK-backed providers). Keyed by
// provider+mode so that switching between providers that share the same
// `*ModeApiModelId` key does not combine one provider's model id with
// another provider's model info.
const selectionMemory = new Map<string, ModelSelection>()
function providerKey(providerId: ProviderId): string {
return providerId.toString()
}
function providerForStorage(providerId: ProviderId): ApiProvider | undefined {
const key = providerKey(providerId)
if (key === "nousresearch") {
return "nousResearch"
}
return key as ApiProvider
}
function memoryKey(providerId: ProviderId, mode: Mode): string {
return `${providerId}:${mode}`
return toLegacyApiProvider(key)
}
function modePair<T>(mode: Mode, plan: T, act: T): T {
@@ -152,18 +73,16 @@ function isModelInfo(value: unknown): value is ModelInfo {
return isRecord(value) && typeof value.supportsPromptCache === "boolean"
}
function isKnownModelIdForProvider(providerId: ProviderId, modelId: string): boolean {
const sdkProviderId = toSdkProviderId(providerId)
return Boolean(
getGeneratedModelsForProvider(sdkProviderId)[modelId] || MODEL_COLLECTIONS_BY_PROVIDER_ID[sdkProviderId]?.models[modelId],
)
}
function readProviderSettingsModelId(providerId: ProviderId): string | undefined {
const model = getProviderSettings(providerId).model
return typeof model === "string" && model.trim().length > 0 ? model.trim() : undefined
}
function readProviderSettingsModelInfo(providerId: ProviderId): ModelInfo | undefined {
const modelInfo = getProviderSettings(providerId).modelInfo
return isModelInfo(modelInfo) ? modelInfo : undefined
}
function fallbackModelInfo(modelId: string): ModelInfo {
return { ...openAiModelInfoSafeDefaults, name: modelId }
}
@@ -206,91 +125,53 @@ function readSelectionFromProviderSettings(providerId: ProviderId): ModelSelecti
return {
providerId,
modelId,
modelInfo: readKnownModelInfoForProvider(providerId, modelId) ?? fallbackModelInfo(modelId),
}
}
function writeStateKey(key: SecretKey | SettingsKey, value: unknown): void {
const stateManager = StateManager.get()
if (isSecretKey(key)) {
stateManager.setSecret(key, typeof value === "string" ? value : undefined)
return
}
if (isSettingsKey(key)) {
stateManager.setGlobalState(key, value as never)
}
}
function writeStateFields(providerId: ProviderId, patch: ProviderConfigPatch): void {
const provider = providerKey(providerId)
for (const key of ["apiKey", "baseUrl", "apiLine", "headers", "region"] as const) {
if (!(key in patch)) {
continue
}
const stateKey = providerConfigStateKeys[key][provider]
if (stateKey) {
const value = typeof patch[key] === "string" ? patchStringValue(patch[key]) : patchValue(patch[key])
writeStateKey(stateKey, value)
}
}
if (provider === "vertex" && "gcp" in patch) {
const gcp = patch.gcp
if (gcp === null || gcp === undefined) {
writeStateKey("vertexProjectId", undefined)
writeStateKey("vertexRegion", undefined)
} else {
if ("projectId" in gcp) writeStateKey("vertexProjectId", patchStringValue(gcp.projectId))
if ("region" in gcp) writeStateKey("vertexRegion", patchStringValue(gcp.region))
}
}
if (provider === "bedrock" && "aws" in patch) {
const aws = patch.aws
if (aws === null || aws === undefined) {
writeStateKey("awsAccessKey", undefined)
writeStateKey("awsSecretKey", undefined)
writeStateKey("awsSessionToken", undefined)
writeStateKey("awsAuthentication", undefined)
writeStateKey("awsProfile", undefined)
writeStateKey("awsBedrockUsePromptCache", undefined)
writeStateKey("awsBedrockEndpoint", undefined)
} else {
if ("accessKey" in aws) writeStateKey("awsAccessKey", patchStringValue(aws.accessKey))
if ("secretKey" in aws) writeStateKey("awsSecretKey", patchStringValue(aws.secretKey))
if ("sessionToken" in aws) writeStateKey("awsSessionToken", patchStringValue(aws.sessionToken))
if ("authentication" in aws) writeStateKey("awsAuthentication", patchStringValue(aws.authentication))
if ("profile" in aws) writeStateKey("awsProfile", patchStringValue(aws.profile))
if ("usePromptCache" in aws) writeStateKey("awsBedrockUsePromptCache", aws.usePromptCache)
if ("endpoint" in aws) writeStateKey("awsBedrockEndpoint", patchStringValue(aws.endpoint))
if ("customModelBaseId" in aws) {
const customModelBaseId = patchStringValue(aws.customModelBaseId)
writeStateKey("planModeAwsBedrockCustomModelBaseId", customModelBaseId)
writeStateKey("actModeAwsBedrockCustomModelBaseId", customModelBaseId)
}
if ("useCrossRegionInference" in aws) writeStateKey("awsUseCrossRegionInference", aws.useCrossRegionInference)
if ("useGlobalInference" in aws) writeStateKey("awsUseGlobalInference", aws.useGlobalInference)
}
}
if (provider === "cline" && "auth" in patch) {
writeStateKey("clineApiKey", patch.auth?.accessToken)
writeStateKey("clineAccountId", patch.auth?.accountId)
modelInfo:
readProviderSettingsModelInfo(providerId) ??
readKnownModelInfoForProvider(providerId, modelId) ??
fallbackModelInfo(modelId),
}
}
function getProviderSettings(providerId: ProviderId): ProviderSettingsRecord {
const settings = getProviderSettingsManager().getProviderSettings(providerId)
const manager = getProviderSettingsManager()
const sdkProviderId = toSdkProviderId(providerId)
const settings = manager.getProviderSettings(sdkProviderId) ?? manager.getProviderSettings(providerId)
return isRecord(settings) ? settings : {}
}
function saveProviderSettings(providerId: ProviderId, next: ProviderSettingsRecord): void {
getProviderSettingsManager().saveProviderSettings({ provider: providerId, ...next }, { setLastUsed: false })
const sdkProviderId = toSdkProviderId(providerId)
getProviderSettingsManager().saveProviderSettings({ provider: sdkProviderId, ...next }, { setLastUsed: false })
}
function mergeProviderSettingsRecord(
base: ProviderSettingsRecord,
patch: Readonly<Record<string, unknown>> | undefined,
): ProviderSettingsRecord {
if (!patch) {
return base
}
const next: ProviderSettingsRecord = { ...base }
for (const [key, value] of Object.entries(patch)) {
if (value === undefined) {
continue
}
if (value === null) {
delete next[key]
continue
}
const existingValue = next[key]
if (isRecord(existingValue) && isRecord(value)) {
next[key] = mergeProviderSettingsRecord(existingValue, value)
} else {
next[key] = value
}
}
return next
}
function writeProviderSettingsFields(providerId: ProviderId, patch: ProviderConfigPatch): void {
const existing = getProviderSettings(providerId)
const next: ProviderSettingsRecord = { ...existing }
const next: ProviderSettingsRecord = mergeProviderSettingsRecord(getProviderSettings(providerId), patch.settings)
for (const key of ["apiKey", "baseUrl", "apiLine", "headers", "region", "auth", "extras"] as const) {
if (key in patch) {
@@ -303,6 +184,50 @@ function writeProviderSettingsFields(providerId: ProviderId, patch: ProviderConf
}
}
if ("sap" in patch) {
const sapPatch = patch.sap
if (sapPatch === null || sapPatch === undefined) {
delete next.sap
} else {
const existingSap = isRecord(next.sap) ? next.sap : {}
const nextSap: ProviderSettingsRecord = { ...existingSap }
for (const [key, value] of Object.entries(sapPatch)) {
if (typeof value === "string" && value.length === 0) {
delete nextSap[key]
} else {
nextSap[key] = value
}
}
if (Object.keys(nextSap).length === 0) {
delete next.sap
} else {
next.sap = nextSap
}
}
}
if ("oca" in patch) {
const ocaPatch = patch.oca
if (ocaPatch === null || ocaPatch === undefined) {
delete next.oca
} else {
const existingOca = isRecord(next.oca) ? next.oca : {}
const nextOca: ProviderSettingsRecord = { ...existingOca }
for (const [key, value] of Object.entries(ocaPatch)) {
if (typeof value === "string" && value.length === 0) {
delete nextOca[key]
} else {
nextOca[key] = value
}
}
if (Object.keys(nextOca).length === 0) {
delete next.oca
} else {
next.oca = nextOca
}
}
}
if ("gcp" in patch) {
const gcpPatch = patch.gcp
if (gcpPatch === null || gcpPatch === undefined) {
@@ -325,6 +250,28 @@ function writeProviderSettingsFields(providerId: ProviderId, patch: ProviderConf
}
}
if ("azure" in patch) {
const azurePatch = patch.azure
if (azurePatch === null || azurePatch === undefined) {
delete next.azure
} else {
const existingAzure = isRecord(next.azure) ? next.azure : {}
const nextAzure: ProviderSettingsRecord = { ...existingAzure }
for (const [key, value] of Object.entries(azurePatch)) {
if (typeof value === "string" && value.length === 0) {
delete nextAzure[key]
} else {
nextAzure[key] = value
}
}
if (Object.keys(nextAzure).length === 0) {
delete next.azure
} else {
next.azure = nextAzure
}
}
}
if ("aws" in patch) {
const awsPatch = patch.aws
if (awsPatch === null || awsPatch === undefined) {
@@ -339,7 +286,11 @@ function writeProviderSettingsFields(providerId: ProviderId, patch: ProviderConf
nextAws[key] = value
}
}
next.aws = nextAws
if (Object.keys(nextAws).length === 0) {
delete next.aws
} else {
next.aws = nextAws
}
}
}
@@ -376,47 +327,42 @@ function getModelIdKey(providerId: ProviderId, mode: Mode): keyof ApiConfigurati
}
function getModelInfoKey(providerId: ProviderId, mode: Mode): (keyof ApiConfiguration & SettingsKey) | undefined {
const keys = modelInfoKeysByProvider[providerKey(providerId)]
const keys = modelInfoKeysByProvider[toLegacyApiProvider(providerKey(providerId))]
return keys ? modePair(mode, keys.plan, keys.act) : undefined
}
function syncedModes(mode: Mode): Mode[] {
return StateManager.get().getGlobalSettingsKey("planActSeparateModelsSetting") ? [mode] : ["plan", "act"]
}
function writeSelectionToState(providerId: ProviderId, mode: Mode, selection: ModelSelection): void {
const updates: Partial<Record<SettingsKey, unknown>> = {}
for (const targetMode of syncedModes(mode)) {
updates[getModelIdKey(providerId, targetMode)] = selection.modelId
const modelInfoKey = getModelInfoKey(providerId, targetMode)
if (modelInfoKey) {
updates[modelInfoKey] = selection.modelInfo
}
selectionMemory.set(memoryKey(providerId, targetMode), { ...selection, providerId })
}
StateManager.get().setGlobalStateBatch(updates as never)
}
function writeSelectionToProviderSettings(providerId: ProviderId, selection: ModelSelection): void {
const next: ProviderSettingsRecord = { ...getProviderSettings(providerId), model: selection.modelId }
// Prune model metadata that earlier builds may have written to providers.json.
delete next.contextWindow
delete next.maxTokens
const next: ProviderSettingsRecord = {
...getProviderSettings(providerId),
model: selection.modelId,
modelInfo: selection.modelInfo,
}
if (selection.modelInfo.contextWindow !== undefined && selection.modelInfo.contextWindow > 0) {
next.contextWindow = selection.modelInfo.contextWindow
}
if (selection.modelInfo.maxTokens !== undefined && selection.modelInfo.maxTokens > 0) {
next.maxTokens = selection.modelInfo.maxTokens
}
saveProviderSettings(providerId, next)
}
function readSelectionFromState(providerId: ProviderId, mode: Mode): ModelSelection | undefined {
const providerSettingsSelection = readSelectionFromProviderSettings(providerId)
if (providerSettingsSelection) {
return providerSettingsSelection
}
const apiConfiguration = StateManager.get().getApiConfiguration()
const modelId = apiConfiguration[getModelIdKey(providerId, mode)]
const modelInfoKey = getModelInfoKey(providerId, mode)
const rememberedSelection = selectionMemory.get(memoryKey(providerId, mode))
const providerSettingsSelection = readSelectionFromProviderSettings(providerId)
if (modelInfoKey) {
const modelInfo = apiConfiguration[modelInfoKey]
if (typeof modelId !== "string" || modelId.length === 0 || !isModelInfo(modelInfo)) {
return providerSettingsSelection
return undefined
}
return { providerId, modelId, modelInfo }
}
@@ -424,21 +370,18 @@ function readSelectionFromState(providerId: ProviderId, mode: Mode): ModelSelect
const activeProvider = mode === "plan" ? apiConfiguration.planModeApiProvider : apiConfiguration.actModeApiProvider
const provider = providerForStorage(providerId)
if (activeProvider !== provider) {
return rememberedSelection ?? providerSettingsSelection
return undefined
}
if (typeof modelId !== "string" || modelId.length === 0) {
return rememberedSelection ?? providerSettingsSelection
return undefined
}
if (!isKnownModelIdForProvider(providerId, modelId)) {
return rememberedSelection ?? providerSettingsSelection
const modelInfo = readKnownModelInfoForProvider(providerId, modelId)
if (!modelInfo) {
return undefined
}
if (!rememberedSelection || rememberedSelection.modelId !== modelId) {
return providerSettingsSelection
}
return rememberedSelection
return { providerId, modelId, modelInfo }
}
/**
@@ -469,7 +412,6 @@ export function createProviderConfigStore(): ProviderConfigStore {
},
write(providerId: ProviderId, patch: ProviderConfigPatch): EffectiveProviderConfig {
writeStateFields(providerId, patch)
writeProviderSettingsFields(providerId, patch)
const config = this.read(providerId)
emit({ kind: "fields", providerId, config })
@@ -477,7 +419,6 @@ export function createProviderConfigStore(): ProviderConfigStore {
},
commitSelection(providerId: ProviderId, mode: Mode, selection: ModelSelection): void {
writeSelectionToState(providerId, mode, selection)
writeSelectionToProviderSettings(providerId, selection)
emit({ kind: "selection", providerId, mode, selection })
},
-50
View File
@@ -1,50 +0,0 @@
// Maps the extension's legacy SAP AI Core ApiConfiguration onto the SDK's
// structured SAP provider options (baseUrl + sap block).
//
// buildSessionConfig() uses this to hand the SDK runtime the same structured
// SAP fields that the legacy UI stores in ApiConfiguration.
import type { ProviderSettings } from "@cline/core"
import type { ApiConfiguration } from "@shared/api"
import type { Mode } from "@shared/storage/types"
export type SapProviderConfig = Pick<ProviderSettings, "baseUrl" | "sap">
function trimString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined
}
return value.trim()
}
export function buildSapProviderConfig(config: ApiConfiguration, mode: Mode): SapProviderConfig {
const sap: NonNullable<SapProviderConfig["sap"]> = {}
const baseUrl = trimString(config.sapAiCoreBaseUrl)
const useOrchestrationMode = config.sapAiCoreUseOrchestrationMode ?? true
const deploymentId = useOrchestrationMode
? undefined
: trimString(mode === "plan" ? config.planModeSapAiCoreDeploymentId : config.actModeSapAiCoreDeploymentId)
const sapFields = {
clientId: trimString(config.sapAiCoreClientId),
clientSecret: trimString(config.sapAiCoreClientSecret),
tokenUrl: trimString(config.sapAiCoreTokenUrl),
resourceGroup: trimString(config.sapAiResourceGroup),
deploymentId,
}
for (const [key, value] of Object.entries(sapFields)) {
if (value !== undefined) {
sap[key as keyof typeof sapFields] = value
}
}
if (Object.keys(sap).length > 0) {
sap.useOrchestrationMode = useOrchestrationMode
}
return {
...(baseUrl !== undefined ? { baseUrl } : {}),
...(Object.keys(sap).length > 0 ? { sap } : {}),
}
}
+448 -19
View File
@@ -3,11 +3,16 @@ import { buildSdkProviderConfig } from "./sdk-api-handler"
const mocks = vi.hoisted(() => {
const providerSettingsManager = {
getProviderConfig: vi.fn(),
getProviderSettings: vi.fn(),
}
return {
getProviderSettingsManager: vi.fn(() => providerSettingsManager),
providerSettingsManager,
stateManager: {
getApiConfiguration: vi.fn(() => ({})),
getRemoteConfigSettings: vi.fn(() => ({})),
},
}
})
@@ -15,6 +20,12 @@ vi.mock("./provider-migration", () => ({
getProviderSettingsManager: mocks.getProviderSettingsManager,
}))
vi.mock("@/core/storage/StateManager", () => ({
StateManager: {
get: () => mocks.stateManager,
},
}))
vi.mock("@shared/services/Logger", () => ({
Logger: {
warn: vi.fn(),
@@ -24,6 +35,436 @@ vi.mock("@shared/services/Logger", () => ({
describe("buildSdkProviderConfig", () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.providerSettingsManager.getProviderConfig.mockReturnValue(undefined)
mocks.providerSettingsManager.getProviderSettings.mockReturnValue(undefined)
mocks.stateManager.getApiConfiguration.mockReturnValue({})
mocks.stateManager.getRemoteConfigSettings.mockReturnValue({})
})
it("prefers SDK provider config and overlays the mode-specific model", () => {
mocks.providerSettingsManager.getProviderConfig.mockImplementation((providerId: string) => {
if (providerId !== "bedrock") {
return undefined
}
return {
providerId: "bedrock",
modelId: "sdk-default",
apiKey: "sdk-bedrock-key",
region: "us-west-2",
aws: {
authentication: "apikey",
customModelBaseId: "base-profile",
},
}
})
const providerConfig = buildSdkProviderConfig(
{
actModeApiProvider: "bedrock",
actModeApiModelId: "bedrock-model",
awsBedrockApiKey: "legacy-bedrock-key",
awsRegion: "us-east-1",
},
"act",
)
expect(providerConfig).toMatchObject({
providerId: "bedrock",
modelId: "bedrock-model",
apiKey: "sdk-bedrock-key",
region: "us-west-2",
aws: {
authentication: "apikey",
customModelBaseId: "base-profile",
},
})
expect(mocks.providerSettingsManager.getProviderConfig).toHaveBeenCalledWith("bedrock", {
includeKnownModels: false,
})
})
it("fills missing persisted Bedrock settings from legacy state for standalone handlers", () => {
mocks.stateManager.getApiConfiguration.mockReturnValue({
awsRegion: "us-east-1",
awsUseGlobalInference: true,
})
mocks.providerSettingsManager.getProviderConfig.mockImplementation((providerId: string) => {
if (providerId !== "bedrock") {
return undefined
}
return {
providerId: "bedrock",
modelId: "sdk-default",
apiKey: "sdk-bedrock-key",
aws: {
authentication: "api-key",
},
}
})
mocks.providerSettingsManager.getProviderSettings.mockImplementation((providerId: string) => {
if (providerId !== "bedrock") {
return undefined
}
return {
provider: "bedrock",
apiKey: "sdk-bedrock-key",
aws: {
authentication: "api-key",
},
}
})
const providerConfig = buildSdkProviderConfig(
{
actModeApiProvider: "bedrock",
actModeApiModelId: "bedrock-model",
awsBedrockApiKey: "legacy-bedrock-key",
},
"act",
)
expect(providerConfig).toMatchObject({
providerId: "bedrock",
modelId: "bedrock-model",
apiKey: "sdk-bedrock-key",
region: "us-east-1",
useGlobalInference: true,
aws: {
authentication: "api-key",
region: "us-east-1",
useGlobalInference: true,
},
})
})
it("does not pass stale Bedrock API keys when legacy credentials auth maps to IAM", () => {
mocks.stateManager.getApiConfiguration.mockReturnValue({
awsAuthentication: "credentials",
awsBedrockApiKey: "stale-bedrock-api-key",
awsRegion: "us-east-1",
})
mocks.providerSettingsManager.getProviderConfig.mockImplementation((providerId: string) => {
if (providerId !== "bedrock") {
return undefined
}
return {
providerId: "bedrock",
modelId: "sdk-default",
apiKey: "persisted-stale-bedrock-api-key",
aws: {
authentication: "iam",
},
}
})
mocks.providerSettingsManager.getProviderSettings.mockImplementation((providerId: string) => {
if (providerId !== "bedrock") {
return undefined
}
return {
provider: "bedrock",
apiKey: "persisted-stale-bedrock-api-key",
aws: {
authentication: "iam",
},
}
})
const providerConfig = buildSdkProviderConfig(
{
actModeApiProvider: "bedrock",
actModeApiModelId: "bedrock-model",
awsAuthentication: "credentials",
awsBedrockApiKey: "stale-bedrock-api-key",
awsRegion: "us-east-1",
},
"act",
)
expect(providerConfig).toMatchObject({
providerId: "bedrock",
modelId: "bedrock-model",
region: "us-east-1",
aws: {
authentication: "iam",
region: "us-east-1",
},
})
expect(providerConfig).not.toHaveProperty("apiKey")
})
it("uses legacy OpenAI-compatible base URL when persisted settings only produce SDK defaults", () => {
mocks.stateManager.getApiConfiguration.mockReturnValue({
openAiBaseUrl: "http://localhost:8000/v1",
azureApiVersion: "2025-01-01-preview",
})
mocks.providerSettingsManager.getProviderConfig.mockImplementation((providerId: string) => {
if (providerId !== "openai-compatible") {
return undefined
}
return {
providerId: "openai-compatible",
modelId: "sdk-model",
apiKey: "sdk-openai-compatible-key",
baseUrl: "https://api.openai.com/v1",
}
})
mocks.providerSettingsManager.getProviderSettings.mockImplementation((providerId: string) => {
if (providerId !== "openai-compatible") {
return undefined
}
return {
provider: "openai-compatible",
apiKey: "sdk-openai-compatible-key",
}
})
const providerConfig = buildSdkProviderConfig(
{
actModeApiProvider: "openai",
actModeOpenAiModelId: "custom-chat-model",
},
"act",
)
expect(providerConfig).toMatchObject({
providerId: "openai-compatible",
modelId: "custom-chat-model",
apiKey: "sdk-openai-compatible-key",
baseUrl: "http://localhost:8000/v1",
azure: {
apiVersion: "2025-01-01-preview",
},
})
})
it("does not return stale persisted providerConfig reasoning fields for standalone handlers", () => {
mocks.providerSettingsManager.getProviderConfig.mockImplementation((providerId: string) => {
if (providerId !== "openai-compatible") {
return undefined
}
return {
providerId: "openai-compatible",
modelId: "sdk-model",
apiKey: "sdk-openai-compatible-key",
thinking: false,
reasoningEffort: "high",
thinkingBudgetTokens: 4096,
}
})
mocks.providerSettingsManager.getProviderSettings.mockImplementation((providerId: string) => {
if (providerId !== "openai-compatible") {
return undefined
}
return {
provider: "openai-compatible",
reasoning: {
enabled: false,
effort: "high",
budgetTokens: 4096,
},
}
})
const providerConfig = buildSdkProviderConfig(
{
actModeApiProvider: "openai",
actModeOpenAiModelId: "custom-chat-model",
},
"act",
)
expect(providerConfig.thinking).toBeUndefined()
expect(providerConfig.reasoningEffort).toBeUndefined()
expect(providerConfig).not.toHaveProperty("thinkingBudgetTokens")
})
it("overlays legacy OCA mode and base URL for standalone handlers", () => {
mocks.stateManager.getApiConfiguration.mockReturnValue({
ocaMode: "internal",
ocaBaseUrl: "https://internal.oca.example/v1",
})
mocks.providerSettingsManager.getProviderConfig.mockImplementation((providerId: string) => {
if (providerId !== "oca") {
return undefined
}
return {
providerId: "oca",
modelId: "sdk-oca-default",
baseUrl: "https://code.aiservice.us-chicago-1.oci.oraclecloud.com/20250206/app/litellm",
}
})
mocks.providerSettingsManager.getProviderSettings.mockImplementation((providerId: string) => {
if (providerId !== "oca") {
return undefined
}
return {
provider: "oca",
baseUrl: "https://stale-migrated.oca.example/v1",
oca: { mode: "external" },
}
})
const providerConfig = buildSdkProviderConfig(
{
actModeApiProvider: "oca",
actModeOcaModelId: "anthropic/claude-3-7-sonnet-20250219",
},
"act",
)
expect(providerConfig).toMatchObject({
providerId: "oca",
modelId: "anthropic/claude-3-7-sonnet-20250219",
baseUrl: "https://internal.oca.example/v1",
oca: {
mode: "internal",
},
})
})
it("fills Vertex runtime config from legacy state", () => {
mocks.stateManager.getApiConfiguration.mockReturnValue({
vertexProjectId: "legacy-project",
vertexRegion: "us-central1",
})
const providerConfig = buildSdkProviderConfig(
{
actModeApiProvider: "vertex",
actModeApiModelId: "gemini-2.5-pro",
vertexProjectId: "legacy-project",
vertexRegion: "us-central1",
},
"act",
)
expect(providerConfig).toMatchObject({
providerId: "vertex",
modelId: "gemini-2.5-pro",
region: "us-central1",
gcp: {
projectId: "legacy-project",
region: "us-central1",
},
})
})
it("prefers persisted Vertex SDK config while preserving the mode-selected model", () => {
mocks.stateManager.getApiConfiguration.mockReturnValue({
vertexProjectId: "legacy-project",
vertexRegion: "us-central1",
})
mocks.providerSettingsManager.getProviderConfig.mockImplementation((providerId: string) => {
if (providerId !== "vertex") {
return undefined
}
return {
providerId: "vertex",
modelId: "sdk-default",
gcp: {
projectId: "sdk-project",
region: "europe-west4",
},
}
})
mocks.providerSettingsManager.getProviderSettings.mockImplementation((providerId: string) => {
if (providerId !== "vertex") {
return undefined
}
return {
provider: "vertex",
gcp: {
projectId: "sdk-project",
region: "europe-west4",
},
}
})
const providerConfig = buildSdkProviderConfig(
{
actModeApiProvider: "vertex",
actModeApiModelId: "gemini-2.5-pro",
vertexProjectId: "legacy-project",
vertexRegion: "us-central1",
},
"act",
)
expect(providerConfig).toMatchObject({
providerId: "vertex",
modelId: "gemini-2.5-pro",
region: "europe-west4",
gcp: {
projectId: "sdk-project",
region: "europe-west4",
},
})
})
it("prefers act-mode SAP and Bedrock legacy fields over single persisted SDK values", () => {
mocks.providerSettingsManager.getProviderConfig.mockImplementation((providerId: string) => {
if (providerId === "sapaicore") {
return {
providerId: "sapaicore",
modelId: "sdk-default",
sap: { deploymentId: "persisted-deployment" },
}
}
if (providerId === "bedrock") {
return {
providerId: "bedrock",
modelId: "sdk-default",
apiKey: "bedrock-key",
aws: { authentication: "api-key", customModelBaseId: "persisted-base" },
}
}
return undefined
})
mocks.providerSettingsManager.getProviderSettings.mockImplementation((providerId: string) => {
if (providerId === "sapaicore") {
return { provider: "sapaicore", sap: { deploymentId: "persisted-deployment" } }
}
if (providerId === "bedrock") {
return {
provider: "bedrock",
apiKey: "bedrock-key",
aws: { authentication: "api-key", customModelBaseId: "persisted-base" },
}
}
return undefined
})
expect(
buildSdkProviderConfig(
{
planModeApiProvider: "sapaicore",
planModeSapAiCoreModelId: "anthropic--claude-3.5-sonnet",
planModeSapAiCoreDeploymentId: "plan-deployment",
actModeSapAiCoreDeploymentId: "act-deployment",
},
"plan",
),
).toMatchObject({
providerId: "sapaicore",
modelId: "anthropic--claude-3.5-sonnet",
sap: { deploymentId: "act-deployment" },
})
expect(
buildSdkProviderConfig(
{
planModeApiProvider: "bedrock",
planModeApiModelId: "bedrock-model",
planModeAwsBedrockCustomModelBaseId: "plan-base",
actModeAwsBedrockCustomModelBaseId: "act-base",
awsAuthentication: "api-key",
awsBedrockApiKey: "bedrock-key",
},
"plan",
),
).toMatchObject({
providerId: "bedrock",
modelId: "bedrock-model",
apiKey: "bedrock-key",
aws: { authentication: "api-key", customModelBaseId: "act-base" },
})
})
it("uses shared Cline OAuth credentials for ClinePass direct handlers", () => {
@@ -56,30 +497,18 @@ describe("buildSdkProviderConfig", () => {
expect(mocks.providerSettingsManager.getProviderSettings).toHaveBeenCalledWith("cline")
})
it("uses provider-specific settings for SDK-backed direct handlers", () => {
mocks.providerSettingsManager.getProviderSettings.mockImplementation((providerId: string) => {
if (providerId !== "v0") {
return undefined
}
return {
provider: "v0",
apiKey: "v0-key",
}
})
it("falls unsupported persisted providers back to the VS Code runtime default", () => {
const providerConfig = buildSdkProviderConfig(
{
actModeApiProvider: "v0",
actModeApiModelId: "v0-1.5-md",
actModeApiProvider: "qwen-code",
actModeApiModelId: "qwen-code-model",
qwenApiKey: "qwen-code-key",
},
"act",
)
expect(providerConfig).toMatchObject({
providerId: "v0",
modelId: "v0-1.5-md",
apiKey: "v0-key",
})
expect(mocks.providerSettingsManager.getProviderSettings).toHaveBeenCalledWith("v0")
expect(providerConfig.providerId).toBe("cline")
expect(providerConfig.modelId).not.toBe("qwen-code-model")
expect(providerConfig).not.toHaveProperty("apiKey", "qwen-code-key")
})
})
+28 -29
View File
@@ -1,19 +1,17 @@
// Replaces classic src/core/api buildApiHandler (see origin/main).
//
// Builds an SDK ApiHandler (from `@cline/llms`) directly from the extension's
// legacy ApiConfiguration. This is the single inference path: the main task
// loop runs through ClineCore (see cline-session-factory.ts), and standalone
// utility callers (commit message generation) use the handler
// returned here. Both share the same provider/model/key/baseUrl resolution so
// Builds an SDK ApiHandler (from `@cline/llms`) from SDK provider settings.
// The main task loop runs through ClineCore (see cline-session-factory.ts), and
// standalone utility callers (commit message generation) use the handler
// returned here. Both share the same SDK-first provider config resolution so
// there is no second source of truth.
import { type ApiHandler, createHandler, type ProviderConfig } from "@cline/llms"
import type { ApiConfiguration } from "@shared/api"
import type { Mode } from "@shared/storage/types"
import { mirrorPlanActApiConfiguration } from "@/core/controller/models/sharedModeConfiguration"
import { fetch } from "@/shared/net"
import { buildBedrockProviderConfig } from "./bedrock-config"
import { resolveApiKey, resolveBaseUrl, resolveModelId, resolveVertexProviderConfig } from "./cline-session-factory"
import { toSdkProviderId } from "./model-catalog/sdk-provider-id"
import { buildRuntimeProviderConfig } from "./cline-session-factory"
export interface BuildApiHandlerOptions {
/**
@@ -30,9 +28,10 @@ export interface BuildApiHandlerOptions {
* Build an SDK `ProviderConfig` from the extension's `ApiConfiguration` for the
* given mode (plan/act).
*
* Reuses the same resolvers the session factory uses to map the legacy config
* onto provider id, model id, API key, and base URL, then converts the provider
* id to the SDK's spelling (e.g. `openai` `openai-compatible`).
* Reads the SDK ProviderSettingsManager first, matching the CLI's ownership
* model. Legacy ApiConfiguration is only a compatibility fallback for installs
* that have not produced SDK provider settings yet. Plan/Act mode changes
* runtime behavior and tool access, not the selected provider/model.
*
* Reasoning handling: the SDK gateway forwards `reasoningEffort` as
* `reasoning.effort` and `thinkingBudgetTokens` as `reasoning.max_tokens`.
@@ -45,36 +44,36 @@ export function buildSdkProviderConfig(
mode: Mode,
options?: BuildApiHandlerOptions,
): ProviderConfig {
const providerId = (mode === "plan" ? configuration.planModeApiProvider : configuration.actModeApiProvider) ?? "cline"
const apiKey = resolveApiKey(providerId, configuration)
const modelId = resolveModelId(providerId, mode, configuration)
const baseUrl = resolveBaseUrl(providerId, configuration)
const sharedConfiguration = mirrorPlanActApiConfiguration(configuration)
const providerId =
(mode === "plan" ? sharedConfiguration.planModeApiProvider : sharedConfiguration.actModeApiProvider) ?? "cline"
const runtimeProviderConfig = buildRuntimeProviderConfig(providerId, mode, sharedConfiguration)
const modelId = runtimeProviderConfig.modelId ?? ""
const thinkingBudgetTokens =
mode === "plan" ? configuration.planModeThinkingBudgetTokens : configuration.actModeThinkingBudgetTokens
const reasoningEffort = mode === "plan" ? configuration.planModeReasoningEffort : configuration.actModeReasoningEffort
const vertexProviderConfig = providerId === "vertex" ? resolveVertexProviderConfig(configuration) : undefined
mode === "plan" ? sharedConfiguration.planModeThinkingBudgetTokens : sharedConfiguration.actModeThinkingBudgetTokens
const reasoningEffort =
mode === "plan" ? sharedConfiguration.planModeReasoningEffort : sharedConfiguration.actModeReasoningEffort
const base: ProviderConfig = {
providerId: toSdkProviderId(providerId),
modelId: modelId ?? "",
apiKey: apiKey ?? "",
baseUrl,
...(vertexProviderConfig ?? {}),
...runtimeProviderConfig,
modelId,
// Use the proxy-aware fetch so gateway providers respect corporate proxy
// configuration (see .clinerules/network.md).
fetch,
onRetryAttempt: configuration.onRetryAttempt,
// Bedrock needs its region + structured AWS auth options forwarded to the
// SDK gateway. Without these, a pasted Bedrock API key / region is dropped.
...(providerId === "bedrock" ? buildBedrockProviderConfig(configuration, mode) : {}),
}
if (options?.disableReasoning) {
// Explicitly turn reasoning off; do not send effort or budget.
return { ...base, thinking: false }
const withoutReasoning = { ...base }
delete withoutReasoning.reasoningEffort
delete withoutReasoning.thinkingBudgetTokens
return { ...withoutReasoning, thinking: false }
}
if (runtimeProviderConfig.thinking !== undefined || runtimeProviderConfig.reasoningEffort !== undefined) {
return base
}
// Send at most one of budget/effort to avoid the "Only one of
@@ -83,6 +83,19 @@ describe("SdkProviderChangeCoordinator", () => {
await vi.waitFor(() => expect(options.sessions.replaceActiveSession).toHaveBeenCalledOnce())
})
it("restarts when current provider fields change through an SDK provider alias", async () => {
const activeSession = makeActiveSession()
const { coordinator, options } = makeCoordinator({
activeSession,
apiConfiguration: { actModeApiProvider: "openai" },
})
coordinator.handleProviderConfigFieldsChanged("openai-compatible")
await vi.waitFor(() => expect(options.sessions.replaceActiveSession).toHaveBeenCalledOnce())
expect(options.sessionConfigBuilder.build).toHaveBeenCalledWith({ cwd: "/workspace", mode: "act" })
})
it("can clear a deferred restart before the session becomes idle", async () => {
const activeSession = makeActiveSession({ isRunning: true })
const { coordinator, options } = makeCoordinator({ activeSession })
@@ -199,6 +212,7 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
const options = {
stateManager: {
getGlobalSettingsKey: vi.fn(() => input.mode ?? "act"),
getApiConfiguration: vi.fn(() => input.apiConfiguration ?? { actModeApiProvider: "anthropic" }),
} as unknown as StateManager,
sessions: {
getActiveSession: vi.fn(() => activeSession),
@@ -245,6 +259,7 @@ interface MakeCoordinatorInput {
activeSession: ReturnType<typeof makeActiveSession>
mode: "act" | "plan"
task: { taskId: string }
apiConfiguration: Record<string, unknown>
}
function makeActiveSession(input: { isRunning?: boolean } = {}) {
@@ -1,6 +1,7 @@
import type { ApiConfiguration } from "@shared/api"
import type { Mode } from "@shared/storage/types"
import type { StateManager } from "@/core/storage/StateManager"
import { areProviderIdsEquivalent } from "@/shared/model-catalog/provider-helpers"
import { Logger } from "@/shared/services/Logger"
import type { SdkMessageCoordinator } from "./sdk-message-coordinator"
import type { SdkModeCoordinator } from "./sdk-mode-coordinator"
@@ -36,6 +37,31 @@ export class SdkProviderChangeCoordinator {
constructor(private readonly options: SdkProviderChangeCoordinatorOptions) {}
handleProviderConfigFieldsChanged(providerId: string): void {
const mode = this.getCurrentMode()
const activeProvider = providerForMode(this.options.stateManager.getApiConfiguration(), mode)
if (!areProviderIdsEquivalent(activeProvider, providerId)) {
return
}
const activeSession = this.options.sessions.getActiveSession()
if (!activeSession) {
Logger.log("[SdkController] Active provider config changed without active session; next task will use new config")
return
}
Logger.log(`[SdkController] Active provider config changed for ${mode}: ${providerId}`)
if (activeSession.isRunning) {
Logger.log("[SdkController] Session is mid-turn; deferring provider config restart")
this.restartPending = true
return
}
this.restartActiveSessionForProviderChange().catch((error) => {
Logger.error("[SdkController] Failed to restart session after provider config change:", error)
})
}
handleApiConfigurationChanged(previous: ApiConfiguration, next: ApiConfiguration): void {
const mode = this.getCurrentMode()
const previousProvider = providerForMode(previous, mode)
+2 -12
View File
@@ -8,7 +8,6 @@ export enum ClineErrorType {
SpendLimit = "spendLimit",
QuotaExceeded = "quotaExceeded",
Entitlement = "entitlement",
OrgClinePassRestriction = "orgClinePassRestriction",
}
interface ErrorDetails {
@@ -45,8 +44,6 @@ interface ErrorDetails {
}
const RATE_LIMIT_PATTERNS = [/status code 429/i, /rate limit/i, /too many requests/i, /quota exceeded/i, /resource exhausted/i]
const ORG_CLINE_PASS_RESTRICTION_MESSAGE = "organization accounts cannot use individual model inference subscriptions"
const ORG_CLINE_PASS_RESTRICTION_USER_MESSAGE = "organization accounts cannot use clinepass subscriptions"
export class ClineError extends Error {
readonly title = "ClineError"
@@ -155,17 +152,10 @@ export class ClineError extends Error {
return ClineErrorType.SpendLimit
}
// ClinePass entitlement errors are user-actionable and should not fall through to generic 403 auth.
// The organization-account variant gets separate copy because subscribing is not the right action.
// Scoped to the individual "not subscribed" case; other ENTITLEMENT_ERROR variants (e.g. org
// accounts) fall through. Checked before the generic auth check since these are returned as 403.
const isEntitlementCode = code === "ENTITLEMENT_ERROR" || details?.code === "ENTITLEMENT_ERROR"
const entitlementText = `${message ?? ""} ${details?.message ?? ""}`.toLowerCase()
if (
isEntitlementCode &&
(entitlementText.includes(ORG_CLINE_PASS_RESTRICTION_MESSAGE) ||
entitlementText.includes(ORG_CLINE_PASS_RESTRICTION_USER_MESSAGE))
) {
return ClineErrorType.OrgClinePassRestriction
}
if (isEntitlementCode && entitlementText.includes("not subscribed to required model plan")) {
return ClineErrorType.Entitlement
}
@@ -54,27 +54,16 @@ describe("ClineError", () => {
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
})
it("should classify the organization ENTITLEMENT_ERROR variant separately from the ClinePass subscription card", () => {
// Org accounts can't use individual subs; this case should not show the personal ClinePass
// subscription card, but it should still get dedicated user-actionable copy.
it("should NOT classify the organization ENTITLEMENT_ERROR variant as Entitlement", () => {
// Org accounts can't use individual subs; this case is intentionally out of scope and
// falls through to generic handling rather than showing the ClinePass card.
const err = new ClineError({
message: "403 Error 403: organization accounts cannot use individual model inference subscriptions",
code: "ENTITLEMENT_ERROR",
status: 403,
})
const result = ClineError.getErrorType(err)
result!.should.equal(ClineErrorType.OrgClinePassRestriction)
;(result !== ClineErrorType.Entitlement).should.be.true()
})
it("should not classify organization restriction text without ENTITLEMENT_ERROR as OrgClinePassRestriction", () => {
const err = new ClineError({
message: "Network error: organization accounts cannot use individual model inference subscriptions",
code: "ERR_NETWORK",
})
const result = ClineError.getErrorType(err)
;(result !== ClineErrorType.OrgClinePassRestriction).should.be.true()
})
})
})
@@ -54,7 +54,6 @@ export class FeatureFlagsService {
flagKeys: FEATURE_FLAGS,
})
this.cacheInfo.flagsPayload = values
Logger.log("Fetched Feature Flag values + " + JSON.stringify(values))
for (const flag of FEATURE_FLAGS) {
const payload = await this.getFeatureFlag(flag).catch(() => false)
@@ -85,10 +84,6 @@ export class FeatureFlagsService {
})
}
Logger.info(
`[FeatureFlagsService] resolving ${flagName}: payload=${JSON.stringify(payload)} flagValue=${JSON.stringify(flagValue)} default=${JSON.stringify(FeatureFlagDefaultValue[flagName])} final=${JSON.stringify(value)} type=${typeof value}`,
)
return value
} catch (error) {
Logger.error(`Error checking if feature flag ${flagName} is enabled:`, error)
-4
View File
@@ -33,22 +33,18 @@ export type ApiProvider =
| "cerebras"
| "sapaicore"
| "groq"
| "poolside"
| "huggingface"
| "huawei-cloud-maas"
| "dify"
| "baseten"
| "vercel-ai-gateway"
| "v0"
| "zai"
| "zai-coding-plan"
| "oca"
| "aihubmix"
| "minimax"
| "hicap"
| "nousResearch"
| "wandb"
| "xiaomi"
export const DEFAULT_API_PROVIDER = "openrouter" as ApiProvider
@@ -1,5 +1,11 @@
import { describe, expect, it } from "vitest"
import { toLegacyApiProvider } from "./provider-helpers"
import {
areProviderIdsEquivalent,
isProviderAllowedByRemoteConfig,
isVscodeUnsupportedProvider,
toLegacyApiProvider,
toVscodeSupportedProvider,
} from "./provider-helpers"
describe("toLegacyApiProvider", () => {
it("up-cases the nousResearch id to its legacy ApiConfiguration spelling", () => {
@@ -7,9 +13,56 @@ describe("toLegacyApiProvider", () => {
expect(toLegacyApiProvider("nousresearch")).toBe("nousResearch")
})
it("maps the SDK OpenAI Compatible provider to the extension's legacy openai id", () => {
expect(toLegacyApiProvider("openai-compatible")).toBe("openai")
})
it("passes through other ids unchanged", () => {
expect(toLegacyApiProvider("deepseek")).toBe("deepseek")
expect(toLegacyApiProvider("anthropic")).toBe("anthropic")
expect(toLegacyApiProvider("openai-codex")).toBe("openai-codex")
expect(toLegacyApiProvider("openai-native")).toBe("openai-native")
})
})
describe("areProviderIdsEquivalent", () => {
it("treats SDK and legacy aliases as the same provider", () => {
expect(areProviderIdsEquivalent("openai", "openai-compatible")).toBe(true)
expect(areProviderIdsEquivalent("openai-compatible", "openai")).toBe(true)
expect(areProviderIdsEquivalent("nousresearch", "nousResearch")).toBe(true)
})
it("does not match unrelated or missing providers", () => {
expect(areProviderIdsEquivalent("openai", "openai-native")).toBe(false)
expect(areProviderIdsEquivalent("anthropic", undefined)).toBe(false)
})
})
describe("isProviderAllowedByRemoteConfig", () => {
it("matches remote configured providers through SDK and legacy aliases", () => {
expect(isProviderAllowedByRemoteConfig("openai", ["openai-compatible"])).toBe(true)
expect(isProviderAllowedByRemoteConfig("openai-compatible", ["openai"])).toBe(true)
})
it("rejects unrelated and missing providers", () => {
expect(isProviderAllowedByRemoteConfig("openai", ["anthropic"])).toBe(false)
expect(isProviderAllowedByRemoteConfig(undefined, ["openai-compatible"])).toBe(false)
})
})
describe("VS Code provider support helpers", () => {
it("identifies providers VS Code intentionally hides until host auth is implemented", () => {
expect(isVscodeUnsupportedProvider("claude-code")).toBe(true)
expect(isVscodeUnsupportedProvider("qwen-code")).toBe(true)
expect(isVscodeUnsupportedProvider("dify")).toBe(true)
expect(isVscodeUnsupportedProvider("openai-codex")).toBe(false)
})
it("falls unsupported or missing providers back to the VS Code default", () => {
expect(toVscodeSupportedProvider("qwen-code")).toBe("cline")
expect(toVscodeSupportedProvider("dify")).toBe("cline")
expect(toVscodeSupportedProvider(undefined)).toBe("cline")
expect(toVscodeSupportedProvider("openai-compatible")).toBe("openai")
expect(toVscodeSupportedProvider("deepseek")).toBe("deepseek")
})
})
@@ -1,5 +1,8 @@
import type { ApiProvider } from "@shared/api"
export const VSCODE_DEFAULT_PROVIDER_ID: ApiProvider = "cline"
const VSCODE_UNSUPPORTED_PROVIDER_IDS = new Set(["claude-code", "qwen-code", "dify"])
/**
* Convert SDK/catalog provider ids to the legacy `ApiProvider` spelling used
* by `ApiConfiguration` keys. Most ids are identical; this helper remains as
@@ -8,8 +11,42 @@ import type { ApiProvider } from "@shared/api"
const SDK_PROVIDER_ID_TO_LEGACY_API_PROVIDER: Partial<Record<string, ApiProvider>> = {
nousResearch: "nousResearch",
nousresearch: "nousResearch",
"openai-compatible": "openai",
} satisfies Partial<Record<string, ApiProvider>>
export function toLegacyApiProvider(providerId: string): ApiProvider {
return SDK_PROVIDER_ID_TO_LEGACY_API_PROVIDER[providerId] ?? (providerId as ApiProvider)
}
export function isVscodeUnsupportedProvider(providerId: string | undefined): boolean {
return providerId ? VSCODE_UNSUPPORTED_PROVIDER_IDS.has(providerId) : false
}
export function toVscodeSupportedProvider(
providerId: string | undefined,
fallback: ApiProvider = VSCODE_DEFAULT_PROVIDER_ID,
): ApiProvider {
if (!providerId || isVscodeUnsupportedProvider(providerId)) {
return fallback
}
return toLegacyApiProvider(providerId)
}
export function areProviderIdsEquivalent(left: string | undefined, right: string | undefined): boolean {
if (!left || !right) {
return false
}
return left === right || toLegacyApiProvider(left) === toLegacyApiProvider(right)
}
export function isProviderAllowedByRemoteConfig(
provider: string | undefined,
remoteConfiguredProviders: readonly string[],
): boolean {
if (!provider) {
return false
}
return remoteConfiguredProviders.some((configuredProvider) => areProviderIdsEquivalent(provider, configuredProvider))
}
@@ -0,0 +1,65 @@
import type { ApiProvider } from "@shared/api"
import type { RemoteConfigFields } from "@shared/storage/state-keys"
import { describe, expect, it } from "vitest"
import { getRemoteLockedProviderFieldPaths } from "./remote-config-locks"
describe("getRemoteLockedProviderFieldPaths", () => {
it("locks LiteLLM API key writes when the key is remote configured", () => {
const locked = getRemoteLockedProviderFieldPaths(
{
remoteConfiguredProviders: ["litellm"],
configuredApiKeys: { litellm: true },
},
"litellm",
)
expect(locked).toEqual(new Set(["apiKey"]))
})
const cases: Array<{
name: string
remoteConfig: Partial<RemoteConfigFields>
providerId: string
expected: string[]
}> = [
{
name: "OpenAI-compatible alias fields",
remoteConfig: {
remoteConfiguredProviders: ["openai-compatible" as ApiProvider],
openAiBaseUrl: "https://remote.example/v1",
openAiHeaders: { "x-remote": "locked" },
azureApiVersion: "2026-01-01-preview",
},
providerId: "openai",
expected: ["baseUrl", "headers", "azure.apiVersion"],
},
{
name: "Bedrock nested AWS fields",
remoteConfig: {
remoteConfiguredProviders: ["bedrock"],
awsRegion: "us-east-1",
awsUseCrossRegionInference: true,
awsUseGlobalInference: true,
awsBedrockEndpoint: "https://bedrock.example",
},
providerId: "bedrock",
expected: ["region", "aws.region", "aws.useCrossRegionInference", "aws.useGlobalInference", "aws.endpoint"],
},
{
name: "Vertex project and region fields",
remoteConfig: {
remoteConfiguredProviders: ["vertex"],
vertexProjectId: "remote-project",
vertexRegion: "us-central1",
},
providerId: "vertex",
expected: ["gcp.projectId", "region", "gcp.region"],
},
]
it.each(cases)("locks $name", ({ remoteConfig, providerId, expected }) => {
const locked = getRemoteLockedProviderFieldPaths(remoteConfig, providerId)
expect(locked).toEqual(new Set(expected))
})
})
@@ -0,0 +1,59 @@
import type { RemoteConfigFields } from "@shared/storage/state-keys"
import { areProviderIdsEquivalent } from "./provider-helpers"
type RemoteConfigKey = keyof RemoteConfigFields
const REMOTE_LOCKED_FIELD_PATHS: Record<string, Partial<Record<RemoteConfigKey, readonly string[]>>> = {
anthropic: {
anthropicBaseUrl: ["baseUrl"],
},
bedrock: {
awsRegion: ["region", "aws.region"],
awsUseCrossRegionInference: ["aws.useCrossRegionInference"],
awsUseGlobalInference: ["aws.useGlobalInference"],
awsBedrockUsePromptCache: ["aws.usePromptCache"],
awsBedrockEndpoint: ["aws.endpoint"],
},
litellm: {
configuredApiKeys: ["apiKey"],
liteLlmBaseUrl: ["baseUrl"],
},
openai: {
openAiBaseUrl: ["baseUrl"],
openAiHeaders: ["headers"],
azureApiVersion: ["azure.apiVersion"],
},
vertex: {
vertexProjectId: ["gcp.projectId"],
vertexRegion: ["region", "gcp.region"],
},
}
function canonicalRemoteProviderId(providerId: string): string {
return areProviderIdsEquivalent(providerId, "openai-compatible") ? "openai" : providerId
}
export function getRemoteLockedProviderFieldPaths(
remoteConfigSettings: Partial<RemoteConfigFields> | undefined,
providerId: string,
): Set<string> {
const locked = new Set<string>()
const configuredProviders = remoteConfigSettings?.remoteConfiguredProviders ?? []
if (!configuredProviders.some((configuredProvider) => areProviderIdsEquivalent(providerId, configuredProvider))) {
return locked
}
const fieldMap = REMOTE_LOCKED_FIELD_PATHS[canonicalRemoteProviderId(providerId)]
if (!fieldMap) {
return locked
}
for (const [key, paths] of Object.entries(fieldMap) as Array<[RemoteConfigKey, readonly string[]]>) {
if (remoteConfigSettings?.[key] !== undefined) {
for (const path of paths) {
locked.add(path)
}
}
}
return locked
}
@@ -1,23 +0,0 @@
import type { ApiProvider } from "@shared/api"
import { describe, expect, it } from "vitest"
import { convertApiConfigurationToProto, convertProtoToApiConfiguration } from "./api-configuration-conversion"
describe("api configuration provider conversion", () => {
it("round-trips SDK provider ids added after the legacy enum list", () => {
const providers: ApiProvider[] = ["poolside", "v0", "xiaomi", "zai-coding-plan"]
for (const provider of providers) {
const proto = convertApiConfigurationToProto({
actModeApiProvider: provider,
planModeApiProvider: provider,
})
// Assert field-by-field instead of toMatchObject: this file is also picked up by
// the mocha integration runner (.vscode-test.mjs globs src/shared/**/*.test.js),
// where vitest's jest-compat matchers like toMatchObject are not available.
const result = convertProtoToApiConfiguration(proto)
expect(result.actModeApiProvider).toBe(provider)
expect(result.planModeApiProvider).toBe(provider)
}
})
})

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