Compare commits

...
Author SHA1 Message Date
Saoud Rizwan 34b2f60394 chore(vscode): bump to 4.0.11 for legacy release 2026-07-24 11:42:32 -07:00
Saoud Rizwan e987c00d7c feat(models): add Claude Opus 5 provider support (legacy) (#12528)
* feat(models): add Claude Opus 5 provider support

Add claude-opus-5 (200k) and claude-opus-5:1m model variants across the
Anthropic, Claude Code, Bedrock, and Vertex catalogs, mirroring the Opus 4.8
setup (same pricing, 1M tiers, global endpoint, adaptive thinking).

- Wire the OpenRouter/Vercel AI Gateway 1m suffix handling and Cline/OpenRouter
  model refresh derivation for anthropic/claude-opus-5 (plus the
  anthropic/claude-5-opus alias, matching the sonnet-5 dual-slug handling)
- Register opus-5 in adaptive thinking detection so it uses the
  reasoning-effort selector path
- Bump the Claude Code opus alias to Opus 5
- Add context window switchers in the Cline and OpenRouter model pickers
- Add provider tests for the new model ids

* fix(models): drop long-context premium from CLAUDE_OPUS_1M_TIERS

Anthropic's pricing docs state Claude 4.6+ models include the full 1M
context window at standard pricing (a 900k-token request bills at the same
per-token rate as a 9k one). The >200k tier in CLAUDE_OPUS_1M_TIERS still
carried the older 2x markup ($10/$37.50, cache 12.5/1.0), overstating
displayed costs for the opus 4.6/4.7/4.8/5 :1m variants. Flatten the upper
tier to the standard $5/$25 (cache 6.25/0.5) rates.
2026-07-24 11:29:50 -07:00
Mikołaj KondratekandClaude Fable 5 c309edd157 feat(telemetry): emit host_plugin_version metadata on all events (legacy) (#12480)
* feat(telemetry): emit host_plugin_version metadata on all events

The host already reports its Cline distribution version over the
hostbridge (getHostVersion.clineVersion — the JetBrains plugin version
on JetBrains, the extension version on VSCode), but telemetry never
attached it: extension_version is always the cline-core bundle version,
so JetBrains events could not be tied to a plugin release.

Attach it as a new optional host_plugin_version metadata field, omitted
when the host does not report one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(telemetry): loop over host version cases instead of interleaving stubs

Review feedback: the two host_plugin_version cases were interleaved via
onFirstCall/onSecondCall stubs across two service instances. Run one
mock-assert-reset cycle per case so the only differences between them —
the host version response and the expected reported value — are visible
in the case table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(telemetry): guarantee stub cleanup in host_plugin_version test

Review feedback: the loop installed process-global stubs and only
restored them on the happy path — a rejected create() or failed
assertion would leak exhausted stubs into subsequent tests and leave
the service undisposed. Use a sinon sandbox restored in finally, and
dispose the service there too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:43:16 +09:00
Ara 8de56b5a96 feat(vscode): add Moonshot Kimi K3 support (#12475)
* feat(vscode): add Moonshot Kimi K3 support

* fix(vscode): scope K3 reasoning replay
2026-07-23 01:23:25 +02:00
Saoud Rizwan 7ab3d95dc7 fix(vscode): enable native tool calling for Kimi K3 models (#12482)
isNextGenOpenSourceModelFamily only matched the 'kimi-k2' substring, so
moonshotai/kimi-k3 failed the next-gen family check and fell back to the
generic XML tool-calling prompt (no tools parameter sent). Kimi K3 is
trained for native tool calling and frequently emits its tool plan into
the reasoning channel instead of XML, producing text-only or empty
responses that surface as 'Invalid API Response: empty or unparsable'
and 'Cline hit repeated tool call failures' on both the OpenRouter and
Cline providers.

Match kimi-k3 as well so K3 gets native tool calling like K2.
2026-07-22 13:34:55 -07:00
Saoud Rizwan a62df28da4 chore(vscode): bump to 4.0.10 for legacy release 2026-07-20 00:13:59 -07:00
Saoud Rizwan 00d9a1f925 feat(vscode): add mistake-limit-reached telemetry to legacy extension (#12402) 2026-07-20 00:01:47 -07:00
Saoud Rizwan b560f1f381 chore(vscode): bump to 4.0.9 for legacy release 2026-07-16 16:18:38 -07:00
Saoud Rizwan 4d60a8afd8 fix(vscode): soften mistake-limit-reached copy (#12353)
* fix(vscode): soften mistake-limit-reached copy

The consecutive-mistake-limit message told users their model was 'less capable' and to switch to a stronger one, which came across as condescending and unhelpful. Replace both the Claude and non-Claude variants with a single neutral message that explains what happened and what the user can do next.

* fix(vscode): shorten mistake-limit-reached copy
2026-07-16 15:54:28 -07:00
Ara 744a87be5d feat(vscode): add GPT-5.6 ChatGPT subscription models (#12324) 2026-07-16 04:59:37 +02:00
9ff0573c2e feat(vscode): add rollout telemetry to legacy extension (#12291)
* feat(vscode): add shared rollout telemetry contract

* test(vscode): cover legacy rollout telemetry propagation

---------

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-14 22:29:32 -07:00
Ara a06af825a2 fix(vscode): handle cumulative OpenAI usage chunks (#12282) 2026-07-15 03:27:42 +02:00
Alex TaboadaandTomás Barreiro 187aa19c00 fix(vscode): load skills including UTF-8 with BOM (#12218)
* fix(vscode): load skills including UTF-8 with BOM

* refactor(vscode): changed BOM format detection logic

---------

Co-authored-by: Tomás Barreiro <52393857+BarreiroT@users.noreply.github.com>
2026-07-13 18:07:54 +02:00
Tomás Barreiro 716172d05c Run tests when opening PRs against the legacy extension (#12259) 2026-07-13 17:53:53 +02:00
Saoud Rizwan fae0c4674b chore(vscode): bump to 4.0.8 for legacy release 2026-07-10 16:45:39 -07:00
John Simone 81a69e9ad9 GCP Vertex provider: add models, add free-form entry for model dropdown (#12143)
* GCP Vertex provider: add models, add custom model entry

* change reasoning UI back to default sliders for new vertex models

* fix: convert cached OpenRouter models to proto type in initializeWebview
2026-07-10 09:26:27 -07:00
Saoud Rizwan e867f4520b chore(vscode): bump to 4.0.7 for legacy release 2026-07-08 19:52:46 -07:00
Saoud Rizwan a2ca96cae1 feat(vscode): add ClinePass limit error (legacy) (#12179)
* feat(vscode): add ClinePass limit error with switch to usage-based billing

* refactor(vscode): simplify ClinePass limit message guards
2026-07-08 16:08:35 -07:00
Saoud Rizwan edc088fe5f fix(vscode): update ClinePass onboarding option copy (#12167)
* fix(vscode): update ClinePass onboarding option copy

* fix(vscode): use link foreground color for ClinePass learn more link

* fix(vscode): open ClinePass learn more link via openUrl RPC

* fix(vscode): match ClinePass learn more link font size to description
2026-07-08 13:08:17 -07:00
Tomás BarreiroandSaoud Rizwan 720cf5ff3f Remove all references to glm 5.1 (#12106)
* remove all references to glm 5.1

* Update other references

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-08 12:17:21 -07:00
Ara 3037b393a8 Remove Cline model picker recommendation copy (#12169) 2026-07-08 12:15:26 -07:00
Saoud Rizwan 7eb7897ff2 feat(vscode): allow selecting Cline free models on ClinePass provider (#12131)
* feat(vscode): allow selecting Cline free models on ClinePass provider

* feat(vscode): show ClinePass models as Subscribed/Free tabs

* feat(vscode): simplify ClinePass subscribed model cards

* fix(vscode): update ClinePass free tab copy

* fix(vscode): label free models with cline provider in chat model badge

* chore(vscode): revert ClinePass route copy changes

* feat(vscode): show descriptions on ClinePass subscribed model cards

* fix(vscode): keep catalog descriptions in ClinePass model info panel
2026-07-08 11:58:56 -07:00
Saoud Rizwan 797565f203 fix(vscode): clarify ClinePass description in provider settings (#12103) 2026-07-06 16:59:29 -07:00
Saoud Rizwan 3d312a4bb8 chore(vscode): drop reverted telemetry entry from 4.0.6 changelog 2026-07-02 10:43:54 -07:00
Saoud Rizwan aa3f38d1d9 Revert "Add diff view telemetry metrics (#12017)"
This reverts commit 264e2c9835.
2026-07-02 10:43:24 -07:00
Saoud Rizwan 302967f679 test(vscode): await extension activation in plus button test to fix flaky CI failure 2026-07-02 10:30:48 -07:00
Saoud Rizwan 8c859c182c chore(vscode): bump to 4.0.6 for legacy release 2026-07-02 10:15:55 -07:00
cline-bot 83e062e743 fix(vscode): resolve legacy-extension lint failures blocking legacy build
biome lint (via bun run lint) fails on the legacy-extension branch with
two pre-existing issues:

- hook-factory.test.ts registers two separate beforeEach hooks
  (lint/suspicious/noDuplicateTestHooks). Merge the Windows timeout
  guard into the primary beforeEach.
- integration.test.ts exports mockProviderInfo from a *.test.ts file
  (lint/suspicious/noExportsInTest), which four sibling test files
  import. Move the fixture into a new non-test helper file,
  test-fixtures.ts (mirroring the existing matcher-test.ts pattern in
  the same directory), and update the importers.

No behavior change; verified bun run check-types, bun run lint, and
bun run compile-standalone all pass after this change.
2026-07-02 06:00:51 -04:00
MaxandMax Paulus 🥪 264e2c9835 Add diff view telemetry metrics (#12017)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-07-01 11:27:44 -07:00
Saoud Rizwan 81a6fba11c fix: generalize model capability warning (#12005) 2026-06-30 13:01:39 -07:00
Saoud Rizwan 9da3c59553 fix(vscode): update Claude Sonnet 5 pricing (#12009)
* fix(vscode): update Claude Sonnet 5 pricing

* fix(vscode): restore Claude Sonnet 4.6 pricing
2026-06-30 12:48:11 -07:00
Saoud Rizwan 0e01b99e76 chore(vscode): bump to 4.0.5 for legacy release 2026-06-30 12:28:05 -07:00
Saoud Rizwan ba5f22f58c feat(vscode): add Claude Sonnet 5 support (#12007) 2026-06-30 12:26:30 -07:00
Saoud Rizwan 1cbfc7197e chore(vscode): bump to 4.0.4 for legacy release 2026-06-29 13:15:03 -07:00
Tomás BarreiroandSaoud Rizwan 4d8f9ea1a3 Remove feature flag from the extension (#11987)
* Remove feature flag from the extension

* fix(vscode): preserve ClinePass provider metadata

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-29 13:07:42 -07:00
Saoud Rizwan 40b0b0c236 chore(vscode): bump to 4.0.3 for legacy release 2026-06-29 10:20:41 -07:00
Tomás Barreiro 8c5e1289bf Remove handler flag (#11978) 2026-06-29 10:15:45 -07:00
Saoud Rizwan 4cddde58ed chore(vscode): bump to 4.0.2 for legacy release 2026-06-28 20:54:04 -07:00
Saoud Rizwan 9d45accc3b fix: default focus chain settings in webview state (#11960)
* fix: default focus chain settings in webview state

* fix: default missing global settings from state manager

* Revert "fix: default missing global settings from state manager"

This reverts commit 76a628689e.
2026-06-28 20:52:52 -07:00
Saoud Rizwan 57760c1e20 fix: polish ClinePass and Z AI model metadata (#11958) 2026-06-28 20:11:11 -07:00
Tomás Barreiro fc33e8dbd9 Fix webview env replacing (#11955) 2026-06-28 18:37:02 -07:00
Saoud Rizwan b1dcaf576a fix(vscode): align ClinePass model resolution and reasoning controls (#11954)
* fix(vscode): show reasoning effort for ClinePass models

* fix(vscode): centralize reasoning effort support

* fix(vscode): normalize Cline recommended Z.ai ids

* fix(vscode): preserve Claude budget reasoning include flag

* fix(vscode): simplify ClinePass reasoning support

* fix(vscode): simplify reasoning effort model detection

* chore(vscode): remove unused ClinePass helper

* chore(vscode): avoid mutating ClinePass model info
2026-06-28 18:17:15 -07:00
Saoud Rizwan 419f2cea73 feat(vscode): improve ClinePass provider UX (#11950)
* feat(vscode): improve ClinePass provider UX

* fix(vscode): handle ClinePass action fallback
2026-06-28 16:33:44 -07:00
Saoud Rizwan 54d8c695a3 fix(vscode): prefer canonical Cline Z.ai model ids (#11951)
* fix(vscode): prefer canonical Cline Z.ai model ids

* fix(vscode): avoid redundant Cline model alias filtering
2026-06-28 16:13:04 -07:00
Saoud Rizwan 5a62ab8564 feat(deepseek): support reasoning effort (incl. xhigh) for thinking models (#11938)
* feat(deepseek): support reasoning effort (incl. xhigh) for thinking models

* fix(deepseek): don't forward 'none' reasoning effort to API

* fix(deepseek): scope reasoning effort selector
2026-06-28 10:55:26 -07:00
Saoud Rizwan f81afb51b5 chore(vscode): bump to 4.0.1 for legacy rollback release 2026-06-27 19:09:05 -07:00
John Choi 864419d8b0 Fix ClinePass onboarding model grouping (#11768) 2026-06-23 17:38:30 -07:00
Tomás Barreiro 218db38544 Add to the status bar [ENG-2216] (#11766)
* Add  to the status bar

* Update tests
2026-06-24 01:24:46 +02:00
Bee a3b3295461 fix(llms): includes session id for OpenRouter caching (#11744)
* Improve OpenRouter sticky session routing

OpenRouter prompt caching was getting weaker cache hit rates because requests did not include a stable session_id. Without that identifier, OpenRouter can route consecutive turns away from the same upstream cache context even when cache_control is present, causing more fresh input billing.

Propagate explicit runtime sessionId metadata from agents into model requests without synthesizing fallback session or conversation ids. Add provider metadata for sticky sessions so OpenRouter maps metadata.sessionId to the JSON body session_id field, while keeping the mechanism extensible for header-based providers.

Apply sticky-session metadata in the AI SDK provider fetch wrapper, preserving explicit wire values when callers already set them. Move trimNonEmpty and omitUndefinedValues into @cline/shared for reuse, and cover the JSON-body, header, and no-fallback paths with tests.

* Tighten sticky session fetch body handling

Treat init.body null as an explicit no-body override so the sticky-session wrapper does not inspect or parse a Request body that the caller has intentionally overridden.

Model fetch body text by source, which removes the unreachable JSON-body injection branch and makes the Request rewrite path explicit. Also type the provider fetch mocks so sticky-session assertions compile cleanly in editors.

* merges model-handle default metadata with per-request metadata
2026-06-23 12:07:17 -07:00
MaxandMax Paulus 🥪 d09270940f fix(hub): validate dashboard websocket host and origin (#11724)
* fix(hub): validate browser websocket origins

* test(hub): cover websocket host and origin gates

* fix(hub): allow explicit bind host for dashboard socket

* fix(hub): default protect browser routes

* fix(hub): normalize default port origins

---------

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-06-23 10:14:25 -07:00
Saoud Rizwan 23c9bf1c05 fix: prevent 'ERROR: EMPTY CONTENT' message when error occurs (#11726)
* fix: prevent empty assistant history replay

* refactor: move tool call filtering after empty guard
2026-06-23 09:33:10 -07:00
Bee 52531d935a refactor: add connector configure path and share catalog to shared pkg (#11730)
* feat(hub): add connector configure path and share catalog via @cline/shared

Add connector.channels/configure/delete_config Hub commands that persist
connector settings to disk without starting connector processes or calling
provider auth APIs. This means settings (including tokens) are saved as-is
without verification; callers are responsible for supplying valid values.

Shared catalog and platform definitions:
- Move ConnectorCatalogEntry, CONNECTOR_CATALOG, listConnectorCatalog, and
  all ConnectorPlatformDef/FieldDef/SecurityDef types + CONNECTOR_PLATFORMS
  into sdk/packages/shared/src/connectors/platforms.ts
- Export everything from @cline/shared index so CLI and Hub use the same
  definitions without duplication
- Reduce apps/cli/src/connectors/catalog.ts and
  apps/cli/src/wizards/connect/platforms.ts to thin re-export shims that
  preserve existing CLI import paths

New Hub connector handlers (sdk/packages/core):
- connector-handlers.ts: handles connector.channels (list available/active/
  configured), connector.configure (validate fields and write settings.json
  under ~/.cline/data/connectors/), connector.delete_config (remove entry
  and clean up empty file)
- Settings are stored as ConnectorSettingsFile (version 1) at
  ~/.cline/data/connectors/settings.json; reads are lenient/defensive
- Wire handlers into hub-server-transport.ts dispatch switch
- connector-handlers.test.ts: unit tests for configure, channels, and
  delete_config covering field validation, conditional fields, security
  constraints, and settings round-trips

Hub WebSocket auth helpers (hub-websocket-server.ts):
- Extract isLocalHubHostName / isLocalHubOrigin as named, tested exports
- Allow unauthenticated WebSocket upgrades from local origins (localhost,
  127.0.0.1, ::1) so the Hub UI can connect without an auth token
- hub-websocket-server.test.ts: extend tests to cover new local-origin logic

Add connector.channels, connector.configure, connector.delete_config to
HubCommandName union in sdk/packages/shared/src/hub.ts

* apply feedback

* export connector settings json path

* apply feedback from robin
2026-06-22 22:25:49 -07:00
Tomás BarreiroandSaoud Rizwan 19d4248381 Add Organization error messages to the Cline CLI (#11737)
* Add Organization error messages to the Cline CLI

* Fix tests

* Fix tests

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-22 17:18:29 -07:00
Tomás Barreiro b2b29678a2 Add a button to unselect an org on ClinePass error (#11738)
* Add a button to unselect an org on ClinePass error

* Fix comments
2026-06-23 01:47:15 +02:00
Bee ad30714608 fix(hub-ui): Use development mode conditionally for connector (#11728)
* fix(hub-ui): Use development mode conditionally for connector

Refactor connector CLI launch logic to conditionally apply Bun-specific
flags (`--conditions=development`) only when running under Bun or Node
runtimes with access to the source entrypoint. When running from a
compiled binary (e.g., packaged Cline app), use the execPath directly
without Bun flags.

- Extract `buildCliConnectCommand` function to encapsulate launcher
  and args resolution logic based on runtime detection
- Use `withResolvedClineBuildEnv` for environment variable setup
- Export `__test__` object to enable unit testing of internal logic
- Add tests covering Bun source entrypoint and compiled binary cases

* address feedback
2026-06-22 16:41:47 -07:00
Bee 9eebc44f24 chore: Optimize Cline Hub webview bundle (#11731)
* Optimize Cline Hub webview bundle

Issue:

The Cline Hub webview build emitted Vite's large chunk warning. The main entry bundle was over 2 MB minified, and the build output included many Shiki language/theme chunks plus eagerly bundled Streamdown math/Mermaid support that the hub did not need on startup.

Changes:

- Split heavyweight Cline Hub views behind React.lazy/Suspense so settings, customization, and chat code do not all land in the initial app shell.

- Replace root Shiki highlighter usage with shiki/core and lazily loaded, explicitly supported languages/themes.

- Route Streamdown through a local HubStreamdown wrapper that uses the local CodeBlock renderer and keeps Mermaid as a lazy diagram plugin.

- Remove unused Streamdown math/code/Mermaid packages, add direct lazy Mermaid support, and add targeted Rolldown chunk groups for Mermaid parser/layout/markup assets.

Before/after bundle measurements:

| Metric | Before | After |

| --- | ---: | ---: |

| Total generated JS | 24,356 KiB | 5,800 KiB |

| Total generated JS gzip | 4,805.5 KiB | 1,434.6 KiB |

| Main entry chunk | 2,044.02 kB | 373.88 kB |

| Main entry gzip | 617.25 kB | 117.83 kB |

Verification:

- bun -F @cline/cline-hub build:webview

- bun biome check apps/cline-hub/src/webview/vite.config.ts apps/cline-hub/src/webview/src/App.tsx apps/cline-hub/src/webview/src/components/ai-elements/code-block.tsx apps/cline-hub/src/webview/src/components/ai-elements/message.tsx apps/cline-hub/src/webview/src/components/ai-elements/reasoning.tsx apps/cline-hub/src/webview/src/components/ai-elements/streamdown.tsx apps/cline-hub/src/webview/package.json

* address feedback
2026-06-22 16:40:01 -07:00
Tomás Barreiro 6ddf48d227 Forcefully send users to their personal dashboard when going to subscribe (#11725)
* Forcefully send users to their personal dashboard when going to subscribe

* fix trailing slash
2026-06-22 22:27:31 +02:00
Saoud Rizwan ee59f81706 chore(cli): release v3.0.29 2026-06-20 00:30:51 -07:00
Saoud Rizwan 92f6e28f13 fix(cli): hide costs for Cline free models (#11686)
* fix(cli): hide Cline free model costs

* fix(cli): harden free model cost lookup
2026-06-20 00:20:07 -07:00
Saoud Rizwan 27a78f0248 chore(sdk): release v0.0.51 2026-06-19 19:15:00 -07:00
Saoud Rizwan 2c4980f42a fix(sdk): resolve Cline Z.ai model metadata aliases (#11685)
* fix(sdk): resolve Cline Z.ai model metadata aliases

* fix(sdk): preserve Cline model alias overrides

* test(sdk): update Cline provider model list expectation
2026-06-19 19:05:47 -07:00
Saoud Rizwan 64c5e48edb Revert "fix(cli): resolve Cline model display names by just model name (#11668)" (#11684)
This reverts commit c497698beb.
2026-06-19 18:27:29 -07:00
Saoud Rizwan 3c23f80a94 fix(sdk): deflake run_commands timeout telemetry tests 2026-06-19 14:59:46 -07:00
Saoud Rizwan 4be362892f chore(cli): release v3.0.28 2026-06-19 14:52:50 -07:00
Saoud Rizwan cdff084652 chore(sdk): release v0.0.50 2026-06-19 14:11:25 -07:00
Robin NewhouseandSaoud Rizwan 299a4a9520 fix(sdk): harden parallel tool guidance (#11598)
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-19 12:25:20 -07:00
Saoud Rizwan c497698beb fix(cli): resolve Cline model display names by just model name (#11668)
* fix(cli): resolve Cline model display names by slug

* fix(cli): type model display metadata

* test(cli): cover model display fallback paths
2026-06-19 12:21:57 -07:00
Ara 292133989b fix(sdk): cap assistant text in provider messages (#11478) 2026-06-19 11:41:04 -07:00
AraandSaoud Rizwan f5aa035a6a fix(sdk): default-on tool result truncation, tightened limits + budget accounting in MessageBuilder (#11475)
* fix(sdk): default-on tool result truncation, name fallback, tool_use budget accounting

- Truncate every tool result (MCP/custom tools included), not just an allowlist
- Resolve tool names from tool_result.name when the paired tool_use is gone
- Count tool_use.input strings toward the aggregate provider request budget
- Protect any binary carrier block ({type, data}) from truncation, not just images
- Don't let failSession cleanup errors mask the original turn error

* fix(sdk): tighten MessageBuilder limits with named options and env overrides

Folds #11474 into the default-on truncation branch and addresses review
feedback from both PRs:

- per-result tool cap drops to 8k chars; MessageBuilderOptions constructor
  with CLINE_MESSAGE_BUILDER_* env overrides for A/B testing
- env parsing is positive-only so '=0' cannot silently disable a limit
  (greptile on #11474)
- aggregate budget stays at 6MB: budget truncation rewrites mid-transcript
  bytes and breaks provider prefix caching, so it must stay a rare overflow
  valve rather than the steady state (johnwschoi on #11474)
- user file attachments get a dedicated 50k cap instead of inheriting the
  aggressive tool-result cap (codex on #11474)
- isBinaryContentLike restricted to known binary block types so textual
  {type, data} payloads can no longer dodge every cap (codex/greptile)
- tool_use.input strings become last-resort budget truncation candidates,
  making the aggregate budget reclaimable when oversized model-generated
  arguments carry the overflow (robinnewhouse/greptile)

* fix(sdk): constrain binary tool result truncation

* style(sdk): remove disallowed comment formatting

* Revert "style(sdk): remove disallowed comment formatting"

This reverts commit 9d8b66726d.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-19 11:15:42 -07:00
Saoud Rizwan bbcae25542 fix(cli): refine marketplace primitive rows (#11659) 2026-06-19 10:57:37 -07:00
Tomás Barreiro 9b914912ac Rename Cline Pass to ClinePass (#11657) 2026-06-19 18:36:07 +02:00
Saoud Rizwan 8c369eeed9 docs(vscode): remove marketplace readme language links (#11658) 2026-06-18 18:09:02 -07:00
189 changed files with 10686 additions and 2263 deletions
+1
View File
@@ -8,6 +8,7 @@ on:
pull_request:
branches:
- main
- legacy-extension
workflow_call:
# Set default permissions for all jobs
+100
View File
@@ -1,5 +1,105 @@
# Changelog
## [4.0.11]
### Added
- Add Claude Opus 5 across the Anthropic, Claude Code, Bedrock, Vertex, Cline, and OpenRouter providers, including 1M context window variants.
- Add Moonshot Kimi K3 support.
- Include the host plugin version in telemetry events.
### Fixed
- Correct pricing for the Claude Opus 1M context variants, which overstated costs for requests above 200k tokens.
- Enable native tool calling for Kimi K3 models, fixing empty responses.
## [4.0.10]
### Added
- Add telemetry to track when Cline reaches the consecutive mistake limit.
## [4.0.9]
### Added
- Add GPT-5.6 ChatGPT subscription models.
### Changed
- Soften and shorten the message shown when Cline hits the consecutive mistake limit.
### Fixed
- Handle cumulative usage snapshots from OpenAI-compatible providers so token counts are no longer over-reported.
- Load skills from files saved as UTF-8 with a byte-order mark (BOM).
## [4.0.8]
### Added
- Add more models to the GCP Vertex provider, plus a free-form entry option in the model dropdown for specifying custom Vertex models.
## [4.0.7]
### Added
- Add a ClinePass limit-reached error with a one-click option to switch to Cline usage-based billing.
- Allow selecting Cline free models on the ClinePass provider, organized into Subscribed and Free tabs with model descriptions.
### Changed
- Refine ClinePass onboarding and provider settings copy, and open the "learn more" link via the in-app URL handler.
- Remove the Cline model picker recommendation copy.
### Removed
- Remove all references to GLM 5.1.
## [4.0.6]
### Fixed
- Generalize the model capability warning so it applies more broadly.
## [4.0.5]
### Added
- Add support for Claude Sonnet 5 across the Anthropic, Bedrock, Vertex, Claude Code, SAP AI Core, OpenRouter, and Vercel AI Gateway providers, including model picker and recommended-model updates.
## [4.0.4]
### Changed
- Fully remove the ClinePass feature flag so ClinePass is available everywhere in the UI — onboarding, settings, the welcome promo banner, and the credit-limit "Switch to ClinePass" action.
## [4.0.3]
### Changed
- Enable the ClinePass provider for all users by removing the feature-flag gate that previously fell back to the standard Cline provider.
## [4.0.2]
### Added
- Add reasoning effort support (including `xhigh`) for DeepSeek thinking models.
- Improve the ClinePass provider experience with clearer reasoning controls and model selection.
### Fixed
- Show reasoning effort controls for ClinePass models and align ClinePass model resolution with the rest of the provider.
- Prefer canonical Cline Z.ai model ids and polish ClinePass and Z.ai model metadata.
- Fix environment variable replacement in the webview.
- Default focus chain settings in webview state so the toggle reflects the correct value on load.
## [4.0.1]
### Changed
- Roll the stable VS Code extension back to the pre-SDK-migration codebase to resolve regressions reported in 4.0.0. This release ships the 3.89.2 extension code under a higher version number so existing 4.0.0 users receive the update. SDK-migration work continues separately on `main`.
## [3.89.2]
### Fixed
+16
View File
@@ -1,5 +1,21 @@
# 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.27",
"version": "3.0.29",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+2 -36
View File
@@ -1,36 +1,2 @@
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 }));
}
export type { ConnectorCatalogEntry } from "@cline/shared";
export { CONNECTOR_CATALOG, listConnectorCatalog } from "@cline/shared";
@@ -125,12 +125,12 @@ describe("buildConnectorStartRequest", () => {
io: { writeln: vi.fn(), writeErr: vi.fn() },
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
systemRules: "Rules",
defaultModel: "cline-pass/glm-5.1",
defaultModel: "cline-pass/glm-5.2",
});
expect(request.provider).toBe("cline-pass");
expect(request.apiKey).toBe("workos:resolved-token");
expect(request.model).toBe("cline-pass/glm-5.1");
expect(request.model).toBe("cline-pass/glm-5.2");
});
it("uses auth material resolved by provider settings manager", async () => {
@@ -153,11 +153,11 @@ describe("buildConnectorStartRequest", () => {
io: { writeln: vi.fn(), writeErr: vi.fn() },
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
systemRules: "Rules",
defaultModel: "cline-pass/glm-5.1",
defaultModel: "cline-pass/glm-5.2",
});
expect(request.provider).toBe("cline-pass");
expect(request.apiKey).toBe("workos:resolved-token");
expect(request.model).toBe("cline-pass/glm-5.1");
expect(request.model).toBe("cline-pass/glm-5.2");
});
});
+145 -2
View File
@@ -27,11 +27,25 @@ 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/";
"https://app.cline.bot/dashboard/subscription?personal=true";
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",
@@ -39,6 +53,15 @@ 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",
@@ -88,7 +111,7 @@ vi.mock("./prompt", () => ({
}));
vi.mock("./session-events", () => ({
subscribeToAgentEvents: vi.fn(() => () => {}),
subscribeToAgentEvents: sessionEventsMocks.subscribeToAgentEvents,
}));
describe("runAgent", () => {
@@ -112,6 +135,9 @@ describe("runAgent", () => {
outputMocks.writeln.mockReset();
outputMocks.emitJsonLine.mockReset();
outputMocks.setActiveCliSession.mockReset();
sessionEventsMocks.listener = undefined;
sessionEventsMocks.subscribeToAgentEvents.mockClear();
vi.unstubAllGlobals();
});
afterEach(() => {
@@ -938,4 +964,121 @@ 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),
);
});
});
+16 -3
View File
@@ -18,6 +18,11 @@ 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,
@@ -184,8 +189,10 @@ export async function runAgent(
let reasoningChunkCount = 0;
let redactedReasoningChunkCount = 0;
const displayedErrorMessages = new Set<string>();
const shouldZeroCost = await shouldZeroClineFreeModelCost(config);
const onAgentEvent = (event: AgentEvent): void => {
const onAgentEvent = (rawEvent: AgentEvent): void => {
const event = zeroCliAgentEventCost(rawEvent, shouldZeroCost);
if (event.type === "content_start" && event.contentType === "reasoning") {
reasoningChunkCount += 1;
if (event.redacted) {
@@ -339,8 +346,14 @@ export async function runAgent(
const usageSummary = await sessionManager.getAccumulatedUsage(
started.sessionId,
);
const aggregateUsage = usageSummary?.aggregateUsage;
const usage = aggregateUsage ?? usageSummary?.usage ?? result.usage;
const aggregateUsage = zeroCliUsageCost(
usageSummary?.aggregateUsage,
shouldZeroCost,
);
const usage = zeroCliUsageCost(
aggregateUsage ?? usageSummary?.usage ?? result.usage,
shouldZeroCost,
);
if (config.outputMode === "json") {
emitJsonLine("stdout", {
+18 -4
View File
@@ -24,6 +24,11 @@ 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,
@@ -153,6 +158,7 @@ export async function runInteractive(
askQuestionRef: tuiAskQuestion,
});
const providerSettingsManager = new ProviderSettingsManager();
let zeroCurrentTurnCost = false;
const sessionRuntime = createInteractiveSessionRuntime({
config,
@@ -166,7 +172,7 @@ export async function runInteractive(
resolveMistakeLimitDecision,
switchToActModeTool,
onAgentEvent: (event) => {
uiEvents.emit("agent", event);
uiEvents.emit("agent", zeroCliAgentEventCost(event, zeroCurrentTurnCost));
},
onTeamEvent: (event) => {
uiEvents.emit("team", event);
@@ -432,6 +438,7 @@ 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);
@@ -478,6 +485,8 @@ export async function runInteractive(
}
input = chatCommandResult.input;
commandOutput = chatCommandResult.commandOutput;
zeroTurnCost = await shouldZeroClineFreeModelCost(config);
zeroCurrentTurnCost = zeroTurnCost;
const {
prompt: userInput,
userImages,
@@ -519,8 +528,9 @@ export async function runInteractive(
}
if (result.finishReason !== "completed") {
if (result.finishReason === "aborted" || isAbortInProgress()) {
const usage = await sessionRuntime.getAccumulatedUsage(
result.usage,
const usage = zeroCliUsageCost(
await sessionRuntime.getAccumulatedUsage(result.usage),
zeroTurnCost,
);
return {
usage,
@@ -535,7 +545,10 @@ export async function runInteractive(
errorText || `Turn finished with ${result.finishReason}`,
);
}
const usage = await sessionRuntime.getAccumulatedUsage(result.usage);
const usage = zeroCliUsageCost(
await sessionRuntime.getAccumulatedUsage(result.usage),
zeroTurnCost,
);
return {
usage,
currentContextSize: getCurrentContextSize(result.messages),
@@ -559,6 +572,7 @@ export async function runInteractive(
});
throw error;
} finally {
zeroCurrentTurnCost = false;
if (!delivery) {
isRunning = false;
clearAbortInProgress();
@@ -3,7 +3,9 @@ import type React from "react";
import { useState } from "react";
import "opentui-spinner/react";
import {
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassSubscriptionUrl,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassSubscriptionError,
} from "../../utils/cline-pass-errors";
import {
@@ -329,6 +331,30 @@ 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;
@@ -427,6 +453,11 @@ 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) $0.12");
).toBe("(12,345 tokens) $0.12");
});
it("omits cost when usage cost is hidden", () => {
@@ -67,6 +67,6 @@ describe("formatStatusBarUsageText", () => {
totalCost: 0.123,
showCost: false,
}),
).toBe("(12,345)");
).toBe("(12,345 tokens)");
});
});
+1 -1
View File
@@ -51,7 +51,7 @@ export function formatStatusBarUsageText(input: {
totalCost: number;
showCost: boolean;
}): string {
const tokens = `(${input.totalTokens.toLocaleString()})`;
const tokens = `(${input.totalTokens.toLocaleString()} tokens)`;
if (!input.showCost) return tokens;
return `${tokens} ${formatCost(input.totalCost)}`;
}
+19 -1
View File
@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import {
formatCliErrorMessage,
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassSubscriptionUrl,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassSubscriptionError,
} from "./cline-pass-errors";
@@ -20,7 +22,23 @@ describe("cline-pass-errors", () => {
it("formats the ClinePass subscription URL", () => {
expect(getClinePassSubscriptionUrl()).toBe(
"https://app.cline.bot/dashboard/subscription/",
"https://app.cline.bot/dashboard/subscription?personal=true",
);
});
it("recognizes and formats organization account individual subscription errors", () => {
const raw =
"403 Error 403: organization accounts cannot use individual model inference subscriptions";
const formatted = getClineOrgIndividualInferenceSubscriptionMessage();
expect(isClineOrgIndividualInferenceSubscriptionErrorMessage(raw)).toBe(
true,
);
expect(
isClineOrgIndividualInferenceSubscriptionErrorMessage(
new Error(formatted),
),
).toBe(true);
expect(formatCliErrorMessage(new Error(raw))).toBe(formatted);
});
});
+30 -1
View File
@@ -1,10 +1,16 @@
import {
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassSubscriptionUrl,
isClineNotSubscribedError,
isClineNotSubscribedMessage,
isClineOrgIndividualInferenceSubscriptionError,
isClineOrgIndividualInferenceSubscriptionMessage,
} from "@cline/core";
export { getClinePassSubscriptionUrl };
export {
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassSubscriptionUrl,
};
function isFormattedClinePassSubscriptionMessage(message: string): boolean {
const normalized = message.trim().toLowerCase();
@@ -32,7 +38,30 @@ 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
@@ -0,0 +1,162 @@
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
@@ -0,0 +1,123 @@
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();
}
+14 -323
View File
@@ -1,325 +1,16 @@
export interface PlatformDef {
id: string;
name: string;
type: "polling" | "webhook" | "hybrid";
hint: string;
fields: FieldDef[];
security?: SecurityDef;
}
import {
CONNECTOR_PLATFORMS,
shouldIncludeConnectorField,
} from "@cline/shared";
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 type {
ConnectorFieldCondition as FieldCondition,
ConnectorFieldDef as FieldDef,
ConnectorPlatformDef as PlatformDef,
ConnectorSecurityDef as SecurityDef,
ConnectorSecurityFieldDef as SecurityFieldDef,
} from "@cline/shared";
export { CONNECTOR_PLATFORMS, 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,
},
],
},
];
export const PLATFORMS = CONNECTOR_PLATFORMS;
export const shouldIncludeField = shouldIncludeConnectorField;
+21 -2
View File
@@ -1,3 +1,5 @@
import { isIP } from "node:net";
export interface ClineHubServerOptions {
host: string;
port: number;
@@ -47,6 +49,9 @@ 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(/\/$/, "");
}
@@ -85,12 +90,26 @@ 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);
url.searchParams.set("roomSecret", roomSecret);
if (roomSecret) {
url.searchParams.set("roomSecret", roomSecret);
}
return url.toString();
}
+42 -9
View File
@@ -4,6 +4,7 @@ import {
handleToolApprovalResponse,
rejectOrphanedApprovals,
} from "./server/approvals";
import { isAuthorizedBrowserToDesktopRequest } from "./server/browser-auth";
import {
browserConfig,
host,
@@ -14,7 +15,11 @@ import {
webviewDistDir,
} from "./server/deps";
import { handleDesktopCommand } from "./server/desktop-commands";
import { createJsonResponse, WebviewAssets } from "./server/http";
import {
createJsonResponse,
isWebviewRoute,
WebviewAssets,
} from "./server/http";
import {
attachHub,
detachHub,
@@ -53,17 +58,33 @@ 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 () => {
@@ -77,6 +98,21 @@ 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 });
}
@@ -85,9 +121,6 @@ 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,
@@ -0,0 +1,359 @@
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
@@ -0,0 +1,134 @@
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)
);
}
@@ -0,0 +1,97 @@
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.",
);
});
});
+86 -23
View File
@@ -1,5 +1,8 @@
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 {
@@ -13,6 +16,68 @@ 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),
@@ -55,23 +120,14 @@ async function runCliConnectCommand(args: string[]): Promise<{
stdout: string;
stderr: string;
}> {
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,
},
);
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,
});
let stdout = "";
let stderr = "";
child.stdout?.setEncoding("utf8");
@@ -157,9 +213,10 @@ export async function startConnectorChannel(
const result = await runCliConnectCommand(cliArgs);
if (result.code !== 0) {
throw new Error(
(result.stderr.trim() || result.stdout.trim() || "connector start failed")
.trim()
.slice(0, 2_000),
normalizeConnectorError(
result.stderr || result.stdout,
"connector start failed",
),
);
}
await waitForConnectorState(() =>
@@ -168,6 +225,11 @@ export async function startConnectorChannel(
return connectorChannelsPayload();
}
export const __test__ = {
buildCliConnectCommand,
normalizeConnectorError,
};
export async function stopConnectorChannel(
args?: Record<string, unknown>,
): Promise<WebviewConnectorChannelsResponse> {
@@ -182,9 +244,10 @@ export async function stopConnectorChannel(
const result = await runCliConnectCommand([channel, "--stop"]);
if (result.code !== 0) {
throw new Error(
(result.stderr.trim() || result.stdout.trim() || "connector stop failed")
.trim()
.slice(0, 2_000),
normalizeConnectorError(
result.stderr || result.stdout,
"connector stop failed",
),
);
}
await waitForConnectorState(
+17
View File
@@ -41,6 +41,23 @@ 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,10 +13,9 @@
"@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",
@@ -27,6 +26,7 @@
"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",
+31 -11
View File
@@ -22,7 +22,14 @@ import {
WrenchIcon,
} from "lucide-react";
import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
lazy,
Suspense,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import {
AlertDialog,
AlertDialogAction,
@@ -53,19 +60,24 @@ import type {
WebviewOutboundMessage,
WebviewSessionSummary,
} from "../../webview-protocol";
import Chat from "./Chat";
import { PageFrame, PageHeader } from "./components/views/page-layout";
import {
type CustomizationSection,
CustomizationSectionView,
} from "./components/views/settings/extensions-view";
import {
type SettingsSection,
SettingsView,
} from "./components/views/settings/settings-view";
import type { CustomizationSection } from "./components/views/settings/extensions-view";
import type { SettingsSection } 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"
@@ -238,6 +250,14 @@ 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);
@@ -1333,7 +1353,7 @@ function App() {
return (
<Shell onNavigate={navigate} version={hubState.coreVersion} view={view}>
{content}
<Suspense fallback={<ViewLoading />}>{content}</Suspense>
</Shell>
);
}
@@ -11,12 +11,13 @@ import {
useState,
} from "react";
import type {
BundledLanguage,
BundledTheme,
HighlighterGeneric,
HighlighterCore,
LanguageRegistration,
ThemedToken,
} from "shiki";
import { createHighlighter } from "shiki";
ThemeRegistration,
} from "shiki/core";
import { createHighlighterCore } from "shiki/core";
import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
import { Button } from "@/components/ui/button";
import {
Select,
@@ -36,6 +37,78 @@ 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;
@@ -108,7 +181,7 @@ const LineSpan = ({
// Types
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string;
language: BundledLanguage;
language: string;
showLineNumbers?: boolean;
};
@@ -128,10 +201,9 @@ const CodeBlockContext = createContext<CodeBlockContextType>({
});
// Highlighter cache (singleton per language)
const highlighterCache = new Map<
string,
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
>();
let highlighterPromise: Promise<HighlighterCore> | undefined;
let themesPromise: Promise<void> | undefined;
const languagePromises = new Map<SupportedCodeLanguage, Promise<void>>();
// Token cache
const tokensCache = new Map<string, TokenizedCode>();
@@ -139,27 +211,44 @@ 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: BundledLanguage) => {
const getTokensCacheKey = (code: string, language: string) => {
const start = code.slice(0, 100);
const end = code.length > 100 ? code.slice(-100) : "";
return `${language}:${code.length}:${start}:${end}`;
};
const getHighlighter = (
language: BundledLanguage,
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
const cached = highlighterCache.get(language);
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);
if (cached) {
return cached;
}
const highlighterPromise = createHighlighter({
langs: [language],
themes: ["github-light", "github-dark"],
});
highlighterCache.set(language, highlighterPromise);
return highlighterPromise;
const languagePromise = LANGUAGE_LOADERS[language]().then((registrations) =>
highlighter.loadLanguage(...registrations),
);
languagePromises.set(language, languagePromise);
return languagePromise;
};
// Create raw tokens for immediate display while highlighting loads
@@ -181,11 +270,16 @@ const createRawTokens = (code: string): TokenizedCode => ({
// Synchronous highlight with callback for async results
export const highlightCode = (
code: string,
language: BundledLanguage,
language: string,
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
callback?: (result: TokenizedCode) => void,
): TokenizedCode | null => {
const tokensCacheKey = getTokensCacheKey(code, language);
const langToUse = normalizeLanguage(language);
if (langToUse === "text") {
return createRawTokens(code);
}
const tokensCacheKey = getTokensCacheKey(code, langToUse);
// Return cached result if available
const cached = tokensCache.get(tokensCacheKey);
@@ -202,11 +296,11 @@ export const highlightCode = (
}
// Start highlighting in background - fire-and-forget async pattern
getHighlighter(language)
getHighlighter()
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then)
.then((highlighter) => {
const availableLangs = highlighter.getLoadedLanguages();
const langToUse = availableLangs.includes(language) ? language : "text";
.then(async (highlighter) => {
await ensureThemes(highlighter);
await ensureLanguage(highlighter, langToUse);
const result = highlighter.codeToTokens(code, {
lang: langToUse,
@@ -376,7 +470,7 @@ export const CodeBlockContent = ({
showLineNumbers = false,
}: {
code: string;
language: BundledLanguage;
language: string;
showLineNumbers?: boolean;
}) => {
// Memoized raw tokens for immediate display
@@ -1,9 +1,5 @@
"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";
@@ -16,7 +12,6 @@ import {
useMemo,
useState,
} from "react";
import { Streamdown } from "streamdown";
import { Button } from "@/components/ui/button";
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group";
import {
@@ -26,6 +21,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { HubStreamdown } from "./streamdown";
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
@@ -316,24 +312,18 @@ export const MessageBranchPage = ({
);
};
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
const streamdownPlugins = { cjk, code, math, mermaid };
export type MessageResponseProps = ComponentProps<typeof HubStreamdown>;
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<Streamdown
<HubStreamdown
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,10 +1,6 @@
"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 {
@@ -17,7 +13,6 @@ import {
useRef,
useState,
} from "react";
import { Streamdown } from "streamdown";
import {
Collapsible,
CollapsibleContent,
@@ -26,6 +21,7 @@ import {
import { cn } from "@/lib/utils";
import { Shimmer } from "./shimmer";
import { HubStreamdown } from "./streamdown";
interface ReasoningContextValue {
isStreaming: boolean;
@@ -204,8 +200,6 @@ export type ReasoningContentProps = ComponentProps<
children: string;
};
const streamdownPlugins = { cjk, code, math, mermaid };
export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent
@@ -216,7 +210,7 @@ export const ReasoningContent = memo(
)}
{...props}
>
<Streamdown plugins={streamdownPlugins}>{children}</Streamdown>
<HubStreamdown>{children}</HubStreamdown>
</CollapsibleContent>
),
);
@@ -0,0 +1,170 @@
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,6 +3,7 @@ import {
Puzzle,
Search,
Server,
Star,
Trash2,
Zap,
} from "lucide-react";
@@ -26,6 +27,7 @@ import {
type MarketplacePrimitiveType,
type MarketplaceTag,
} from "@/lib/marketplace";
import { cn } from "@/lib/utils";
import { CommandBadge, PageFrame, PageHeader } from "./page-layout";
type EntryActionState =
@@ -114,6 +116,13 @@ 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();
}
@@ -257,6 +266,8 @@ function MarketplaceEntryCard({
onToggleExpanded,
onUninstall,
matchedLocalItems = [],
showFeatured = true,
showTags = true,
sourceLabel,
tagLabels,
}: {
@@ -269,6 +280,8 @@ function MarketplaceEntryCard({
onToggleExpanded: (entry: MarketplaceEntry) => void;
onUninstall: (entry: MarketplaceEntry) => void;
matchedLocalItems?: MarketplaceLocalInstalledItem[];
showFeatured?: boolean;
showTags?: boolean;
sourceLabel?: string;
tagLabels: Map<string, string>;
}) {
@@ -299,15 +312,63 @@ 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="flex min-w-0 flex-1 items-center gap-3">
<div
className={cn(
"flex min-w-0 flex-1 items-center gap-2",
!installed && "pr-24",
)}
>
<EntryIcon className="h-4 w-4 shrink-0 text-primary" />
<h2 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">
<h2 className="min-w-0 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 ? (
@@ -349,23 +410,34 @@ function MarketplaceEntryCard({
) : null}
</div>
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
{entry.description}
</p>
<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}
{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>
))}
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
{entry.description}
</p>
{statusMessage}
</div>
) : null}
{installed ? (
<div className="flex shrink-0 flex-col items-start gap-1 sm:items-end sm:justify-end">
{actionButton}
</div>
) : null}
</div>
{matchedLocalItems.some((item) => item.renderMatchedDetails) ? (
<div className="grid gap-2" data-marketplace-entry-details>
@@ -377,36 +449,6 @@ 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}
@@ -415,7 +457,7 @@ function MarketplaceEntryCard({
if (!hasExpandableDetails) {
return (
<div className="grid gap-3 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20">
<div className="relative grid gap-2 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20">
{content}
</div>
);
@@ -426,7 +468,7 @@ function MarketplaceEntryCard({
<div
aria-expanded={expanded}
aria-label={`${expanded ? "Collapse" : "Expand"} ${entry.name}`}
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"
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"
onClick={(event) => {
if (
event.target instanceof HTMLElement &&
@@ -487,6 +529,7 @@ function MarketplaceSection({
emptyMessage,
entries,
expandedEntryKey,
headerContent,
installedEntryKeys,
installedStatusReady,
localOnlyInstalledItems = [],
@@ -494,6 +537,8 @@ function MarketplaceSection({
onInstall,
onToggleExpanded,
onUninstall,
showFeaturedBadges = true,
showEntryTags = true,
sourceLabel,
tagLabels,
title,
@@ -502,6 +547,7 @@ function MarketplaceSection({
emptyMessage: string;
entries: MarketplaceEntry[];
expandedEntryKey: string | null;
headerContent?: ReactNode;
installedEntryKeys: Set<string>;
installedStatusReady: boolean;
localOnlyInstalledItems?: MarketplaceLocalInstalledItem[];
@@ -509,6 +555,8 @@ 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;
@@ -520,6 +568,7 @@ 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())}
@@ -537,6 +586,8 @@ function MarketplaceSection({
onToggleExpanded={onToggleExpanded}
onUninstall={onUninstall}
matchedLocalItems={matchedLocalItemsByEntryKey?.get(key) ?? []}
showFeatured={showFeaturedBadges}
showTags={showEntryTags}
sourceLabel={sourceLabel}
tagLabels={tagLabels}
/>
@@ -626,19 +677,48 @@ export function MarketplaceView({
);
const primitiveEntries = useMemo(
() => catalog?.entries.filter((entry) => entry.type === primitive) ?? [],
() =>
(catalog?.entries.filter((entry) => entry.type === primitive) ?? []).sort(
compareFeaturedEntries,
),
[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 primitiveEntries) {
for (const entry of marketplaceEntriesBeforeTag) {
for (const tag of entry.tags) {
counts.set(tag, (counts.get(tag) ?? 0) + 1);
}
}
return counts;
}, [primitiveEntries]);
}, [marketplaceEntriesBeforeTag]);
const primitiveTags = useMemo(
() =>
@@ -646,26 +726,20 @@ export function MarketplaceView({
[catalog?.tags, tagCounts],
);
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 catalogEntries = useMemo(
() =>
marketplaceEntriesBeforeTag.filter(
(entry) => !selectedTag || entry.tags.includes(selectedTag),
),
[marketplaceEntriesBeforeTag, selectedTag],
);
const matchedLocalItemsByEntryKey = useMemo(() => {
const matched = new Map<string, MarketplaceLocalInstalledItem[]>();
for (const item of installedItems ?? []) {
for (const entry of filteredEntries) {
for (const entry of installedEntries) {
const key = entryKey(entry);
if (
!installedEntryKeys.has(key) ||
!entryMatchesLocalItem(entry, item)
) {
if (!entryMatchesLocalItem(entry, item)) {
continue;
}
const items = matched.get(key) ?? [];
@@ -674,49 +748,78 @@ export function MarketplaceView({
}
}
return matched;
}, [filteredEntries, installedEntryKeys, installedItems]);
}, [installedEntries, installedItems]);
const matchedLocalItemKeys = useMemo(
() =>
new Set(
[...matchedLocalItemsByEntryKey.values()].flatMap((items) =>
items.map((item) => item.key),
),
),
[matchedLocalItemsByEntryKey],
);
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 localOnlyInstalledItems = useMemo(
() =>
(installedItems ?? []).filter(
(item) => !matchedLocalItemKeys.has(item.key),
),
[installedItems, matchedLocalItemKeys],
);
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 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 clearFilters = () => {
setQuery("");
setSelectedTag(null);
};
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 setEntryState = (entry: MarketplaceEntry, state: EntryActionState) => {
const key = entryKey(entry);
@@ -863,43 +966,7 @@ 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
@@ -914,6 +981,8 @@ export function MarketplaceView({
onInstall={installEntry}
onToggleExpanded={toggleExpanded}
onUninstall={uninstallEntry}
showFeaturedBadges={false}
showEntryTags={false}
sourceLabel="Marketplace"
tagLabels={tagLabels}
title="Installed"
@@ -924,6 +993,7 @@ 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 catalog.",
Skills: "Manage installed skills and add new skills from the catalog.",
MCP: "Manage installed MCP servers and add new servers from the marketplace.",
Skills: "Manage installed skills and add new skills from the marketplace.",
Agents: "Review configured agents discovered from local settings.",
Plugins: "Manage installed plugins and add new plugins from the catalog.",
Plugins: "Manage installed plugins and add new plugins from the marketplace.",
Tools: "Inspect built-in tools and tools contributed by plugins.",
};
@@ -17,6 +17,7 @@ export type MarketplaceEntry = {
id: string;
type: MarketplacePrimitiveType;
name: string;
featured?: boolean;
tagline: string;
description: string;
tags: string[];
@@ -100,7 +101,7 @@ export async function fetchMarketplaceCatalog(): Promise<MarketplaceCatalog> {
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(`Failed to fetch marketplace catalog: ${response.status}`);
throw new Error(`Failed to fetch marketplace: ${response.status}`);
}
const data = await response.json();
const baseUrl = typeof data?.baseUrl === "string" ? data.baseUrl : undefined;
@@ -152,6 +153,10 @@ 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,6 +3,27 @@ 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: {
@@ -25,5 +46,13 @@ export default defineConfig({
outDir: "../../dist/webview",
emptyOutDir: true,
cssMinify: "esbuild",
chunkSizeWarningLimit: 600,
rolldownOptions: {
output: {
codeSplitting: {
groups: mermaidChunkGroups,
},
},
},
},
});
-4
View File
@@ -1,7 +1,3 @@
<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
View File
@@ -126,6 +126,9 @@ const copyWasmFiles = {
const buildEnvVars = {
"import.meta.url": "_importMetaUrl",
"process.env.IS_STANDALONE": JSON.stringify(standalone ? "true" : "false"),
// Always inline these values so ordinary builds cannot be mislabeled by a
// user's runtime environment. Only the combined rollout workflow sets them.
"process.env.CLINE_ROLLOUT_VARIANT": JSON.stringify(process.env.CLINE_ROLLOUT_VARIANT || ""),
}
if (production) {
+1 -1
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": "3.89.2",
"version": "4.0.11",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
+8
View File
@@ -331,6 +331,10 @@ message ModelsApiOptions {
optional OpenRouterModelInfo act_mode_cline_model_info = 236;
optional string act_mode_cline_pass_model_id = 237;
optional OpenRouterModelInfo act_mode_cline_pass_model_info = 238;
optional bool plan_mode_vertex_custom_model_selected = 239;
optional OpenRouterModelInfo plan_mode_vertex_custom_model_info = 240;
optional bool act_mode_vertex_custom_model_selected = 241;
optional OpenRouterModelInfo act_mode_vertex_custom_model_info = 242;
}
// Request for updating API configuration (legacy - uses combined configuration)
@@ -698,4 +702,8 @@ message ModelsApiConfiguration {
optional OpenRouterModelInfo act_mode_cline_model_info = 241;
optional string act_mode_cline_pass_model_id = 242;
optional OpenRouterModelInfo act_mode_cline_pass_model_info = 243;
optional bool plan_mode_vertex_custom_model_selected = 244;
optional OpenRouterModelInfo plan_mode_vertex_custom_model_info = 245;
optional bool act_mode_vertex_custom_model_selected = 246;
optional OpenRouterModelInfo act_mode_vertex_custom_model_info = 247;
}
+4
View File
@@ -287,6 +287,10 @@ message Settings {
optional OpenRouterModelInfo act_mode_cline_model_info = 181;
optional bool show_feature_tips = 182;
optional bool lazy_teammate_mode_enabled = 183;
optional bool plan_mode_vertex_custom_model_selected = 184;
optional OpenRouterModelInfo plan_mode_vertex_custom_model_info = 185;
optional bool act_mode_vertex_custom_model_selected = 186;
optional OpenRouterModelInfo act_mode_vertex_custom_model_info = 187;
}
message State {
@@ -0,0 +1,104 @@
import "should"
import {
type ApiConfiguration,
clinePassDefaultModelId,
clinePassModelInfoSaneDefaults,
clinePassModels,
type ModelInfo,
} from "@shared/api"
import type { Mode } from "@shared/storage/types"
import sinon from "sinon"
import { ClineAccountService } from "@/services/account/ClineAccountService"
import { AuthService } from "@/services/auth/AuthService"
import { buildApiHandler } from "../index"
describe("buildApiHandler", () => {
beforeEach(() => {
sinon.stub(ClineAccountService, "getInstance").returns({} as any)
sinon.stub(AuthService, "getInstance").returns({} as any)
})
afterEach(() => {
sinon.restore()
})
const buildClinePassHandler = (configuration: Partial<ApiConfiguration>, mode: Mode = "act") =>
buildApiHandler(
{
planModeApiProvider: "cline-pass",
actModeApiProvider: "cline-pass",
...configuration,
} as ApiConfiguration,
mode,
)
describe("cline-pass provider", () => {
const freeModelInfo: ModelInfo = {
...clinePassModelInfoSaneDefaults,
maxTokens: 32_768,
contextWindow: 256_000,
description: "A free model",
}
it("passes a free (non cline-pass prefixed) model id through with its stored info", () => {
const handler = buildClinePassHandler({
actModeClinePassModelId: "kwaipilot/kat-coder-pro",
actModeClinePassModelInfo: freeModelInfo,
})
const model = handler.getModel()
model.id.should.equal("kwaipilot/kat-coder-pro")
model.info.should.deepEqual(freeModelInfo)
})
it("passes a :free suffixed model id through with its stored info", () => {
const handler = buildClinePassHandler({
actModeClinePassModelId: "arcee-ai/trinity-large-preview:free",
actModeClinePassModelInfo: freeModelInfo,
})
const model = handler.getModel()
model.id.should.equal("arcee-ai/trinity-large-preview:free")
model.info.should.deepEqual(freeModelInfo)
})
it("falls back to sane defaults for a free model id without stored info", () => {
const handler = buildClinePassHandler({
actModeClinePassModelId: "kwaipilot/kat-coder-pro",
})
const model = handler.getModel()
model.id.should.equal("kwaipilot/kat-coder-pro")
model.info.should.deepEqual(clinePassModelInfoSaneDefaults)
})
it("resolves cline-pass ids against the static model table", () => {
const handler = buildClinePassHandler({
actModeClinePassModelId: "cline-pass/glm-5.2",
})
const model = handler.getModel()
model.id.should.equal("cline-pass/glm-5.2")
model.info.should.deepEqual(clinePassModels["cline-pass/glm-5.2"])
})
it("falls back to the default pass model when no model id is configured", () => {
const handler = buildClinePassHandler({})
const model = handler.getModel()
model.id.should.equal(clinePassDefaultModelId)
model.info.should.deepEqual(clinePassModels[clinePassDefaultModelId])
})
it("resolves plan and act mode model ids independently", () => {
const configuration: Partial<ApiConfiguration> = {
planModeClinePassModelId: "kwaipilot/kat-coder-pro",
planModeClinePassModelInfo: freeModelInfo,
actModeClinePassModelId: "cline-pass/glm-5.2",
}
buildClinePassHandler(configuration, "plan").getModel().id.should.equal("kwaipilot/kat-coder-pro")
buildClinePassHandler(configuration, "act").getModel().id.should.equal("cline-pass/glm-5.2")
})
})
})
+21 -11
View File
@@ -1,8 +1,13 @@
import { ApiConfiguration, clinePassDefaultModelId, ModelInfo, QwenApiRegions, resolveClinePassModelInfo } from "@shared/api"
import {
ApiConfiguration,
buildModelInfoNameMap,
clinePassDefaultModelId,
ModelInfo,
QwenApiRegions,
resolveClinePassModelInfo,
} from "@shared/api"
import { Mode } from "@shared/storage/types"
import { featureFlagsService } from "@/services/feature-flags"
import { ClineStorageMessage } from "@/shared/messages/content"
import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
import { Logger } from "@/shared/services/Logger"
import { ClineTool } from "@/shared/tools"
import { AIhubmixHandler } from "./providers/aihubmix"
@@ -80,10 +85,7 @@ function createHandlerForProvider(
options: Omit<ApiConfiguration, "apiProvider">,
mode: Mode,
): ApiHandler {
const effectiveApiProvider =
apiProvider === "cline-pass" && !featureFlagsService.getBooleanFlagEnabled(FeatureFlag.CLINE_PASS) ? "cline" : apiProvider
switch (effectiveApiProvider) {
switch (apiProvider) {
case "anthropic":
return new AnthropicHandler({
onRetryAttempt: options.onRetryAttempt,
@@ -136,6 +138,8 @@ function createHandlerForProvider(
vertexProjectId: options.vertexProjectId,
vertexRegion: options.vertexRegion,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
vertexCustomModelInfo:
mode === "plan" ? options.planModeVertexCustomModelInfo : options.actModeVertexCustomModelInfo,
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
geminiApiKey: options.geminiApiKey,
@@ -204,6 +208,7 @@ function createHandlerForProvider(
onRetryAttempt: options.onRetryAttempt,
deepSeekApiKey: options.deepSeekApiKey,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
})
case "requesty":
return new RequestyHandler({
@@ -289,10 +294,15 @@ function createHandlerForProvider(
mode === "plan" ? options.planModeClinePassModelId : options.actModeClinePassModelId
const configuredClinePassModelInfo =
mode === "plan" ? options.planModeClinePassModelInfo : options.actModeClinePassModelInfo
const clineModelId = configuredClinePassModelId?.startsWith("cline-pass/")
? configuredClinePassModelId
: clinePassDefaultModelId
const clineModelInfo = configuredClinePassModelInfo || resolveClinePassModelInfo(clineModelId)
// ClinePass users may also select Cline free models (OpenRouter-style ids without
// the cline-pass/ prefix), so pass any configured id through to the Cline API.
const clineModelId = configuredClinePassModelId || clinePassDefaultModelId
const clineModelInfo = resolveClinePassModelInfo(
clineModelId,
configuredClinePassModelInfo
? buildModelInfoNameMap({ [clineModelId]: configuredClinePassModelInfo })
: undefined,
)
return new ClineHandler({
onRetryAttempt: options.onRetryAttempt,
clineAccountId: options.clineAccountId,
@@ -64,6 +64,30 @@ describe("AnthropicHandler", () => {
result.info.should.deepEqual(anthropicModels["claude-opus-4-7:1m"])
})
it("should return the Opus 5 model when configured", () => {
const handler = new AnthropicHandler({
apiKey: "test-api-key",
apiModelId: "claude-opus-5",
})
const result = handler.getModel()
result.id.should.equal("claude-opus-5")
result.info.should.deepEqual(anthropicModels["claude-opus-5"])
})
it("should return the Opus 5 1m model when configured", () => {
const handler = new AnthropicHandler({
apiKey: "test-api-key",
apiModelId: "claude-opus-5:1m",
})
const result = handler.getModel()
result.id.should.equal("claude-opus-5:1m")
result.info.should.deepEqual(anthropicModels["claude-opus-5:1m"])
})
it("should return the 4.8 model when configured", () => {
const handler = new AnthropicHandler({
apiKey: "test-api-key",
@@ -215,11 +215,21 @@ describe("AwsBedrockHandler", () => {
bedrockModels["anthropic.claude-opus-4-8:1m"].supportsGlobalEndpoint.should.equal(true)
})
it("should mark Bedrock Opus 5 variants as global-endpoint capable", () => {
bedrockModels["anthropic.claude-opus-5"].supportsGlobalEndpoint.should.equal(true)
bedrockModels["anthropic.claude-opus-5:1m"].supportsGlobalEndpoint.should.equal(true)
})
it("should mark Bedrock Fable 5 variants as global-endpoint capable", () => {
bedrockModels["anthropic.claude-fable-5"].supportsGlobalEndpoint.should.equal(true)
bedrockModels["anthropic.claude-fable-5:1m"].supportsGlobalEndpoint.should.equal(true)
})
it("should mark Bedrock Sonnet 5 variants as global-endpoint capable", () => {
bedrockModels["anthropic.claude-sonnet-5"].supportsGlobalEndpoint.should.equal(true)
bedrockModels["anthropic.claude-sonnet-5:1m"].supportsGlobalEndpoint.should.equal(true)
})
it("should include Vertex Opus 4.7 variants in the derived global model list", () => {
vertexModels["claude-opus-4-7"].supportsGlobalEndpoint.should.equal(true)
vertexModels["claude-opus-4-7:1m"].supportsGlobalEndpoint.should.equal(true)
@@ -234,6 +244,13 @@ describe("AwsBedrockHandler", () => {
vertexGlobalModels.should.have.property("claude-opus-4-8:1m")
})
it("should include Vertex Opus 5 variants in the derived global model list", () => {
vertexModels["claude-opus-5"].supportsGlobalEndpoint.should.equal(true)
vertexModels["claude-opus-5:1m"].supportsGlobalEndpoint.should.equal(true)
vertexGlobalModels.should.have.property("claude-opus-5")
vertexGlobalModels.should.have.property("claude-opus-5:1m")
})
it("should include Vertex Fable 5 variants in the derived global model list", () => {
vertexModels["claude-fable-5"].supportsGlobalEndpoint.should.equal(true)
vertexModels["claude-fable-5:1m"].supportsGlobalEndpoint.should.equal(true)
@@ -990,6 +1007,19 @@ describe("AwsBedrockHandler", () => {
modelId.should.equal("jp.anthropic.claude-sonnet-4-6")
})
it("should apply JP cross-region prefix for sonnet 5", async () => {
const jpOptions: AwsBedrockHandlerOptions = {
...mockOptions,
awsUseCrossRegionInference: true,
apiModelId: "anthropic.claude-sonnet-5",
awsRegion: "ap-northeast-1",
}
const jpHandler = new AwsBedrockHandler(jpOptions)
const modelId = await jpHandler.getModelId()
modelId.should.equal("jp.anthropic.claude-sonnet-5")
})
it("should apply global cross-region prefix for supported models", async () => {
const globalOptions: AwsBedrockHandlerOptions = {
...mockOptions,
@@ -396,6 +396,26 @@ describe("ClaudeCodeHandler", () => {
model.info.contextWindow.should.equal(1_000_000)
})
it("should support Opus 5 model id", () => {
const handler = new ClaudeCodeHandler({
apiModelId: "claude-opus-5",
})
const model = handler.getModel()
model.id.should.equal("claude-opus-5")
model.info.contextWindow.should.equal(200_000)
})
it("should support Opus 5 1m model id", () => {
const handler = new ClaudeCodeHandler({
apiModelId: "claude-opus-5[1m]",
})
const model = handler.getModel()
model.id.should.equal("claude-opus-5[1m]")
model.info.contextWindow.should.equal(1_000_000)
})
it("should support Opus 4.8 model id", () => {
const handler = new ClaudeCodeHandler({
apiModelId: "claude-opus-4-8",
@@ -426,6 +446,26 @@ describe("ClaudeCodeHandler", () => {
model.info.contextWindow.should.equal(200_000)
})
it("should support Sonnet 5 model id", () => {
const handler = new ClaudeCodeHandler({
apiModelId: "claude-sonnet-5",
})
const model = handler.getModel()
model.id.should.equal("claude-sonnet-5")
model.info.contextWindow.should.equal(200_000)
})
it("should support Sonnet 5 1m model id", () => {
const handler = new ClaudeCodeHandler({
apiModelId: "claude-sonnet-5[1m]",
})
const model = handler.getModel()
model.id.should.equal("claude-sonnet-5[1m]")
model.info.contextWindow.should.equal(1_000_000)
})
it("should support Fable 5 1m model id", () => {
const handler = new ClaudeCodeHandler({
apiModelId: "claude-fable-5[1m]",
@@ -180,7 +180,7 @@ describe("ClineHandler", () => {
const fakeClient = { chat: { completions: { create: sinon.stub().rejects(apiError) } } }
sinon.stub(handler as any, "ensureClient").resolves(fakeClient as any)
sinon.stub(handler as any, "getFreeModelIdSet").resolves(new Set())
sinon.stub(handler, "getModel").returns({ id: "cline-pass/glm-5.1", info: openRouterDefaultModelInfo })
sinon.stub(handler, "getModel").returns({ id: "cline-pass/glm-5.2", info: openRouterDefaultModelInfo })
let thrown: unknown
try {
@@ -191,7 +191,7 @@ describe("ClineHandler", () => {
thrown = e
}
const clineError = ClineError.transform(thrown, "cline-pass/glm-5.1", "cline-pass")
const clineError = ClineError.transform(thrown, "cline-pass/glm-5.2", "cline-pass")
clineError.isErrorType(ClineErrorType.Entitlement).should.be.true()
})
})
@@ -6,8 +6,10 @@ import { MoonshotHandler } from "../moonshot"
interface MoonshotRequestPayload {
model: string
temperature: number
max_tokens: number
messages?: Array<{ role: string; reasoning_content?: string }>
temperature?: number
max_tokens?: number
max_completion_tokens?: number
}
describe("MoonshotHandler", () => {
@@ -47,7 +49,66 @@ describe("MoonshotHandler", () => {
const payload = createStub.firstCall.args[0] as MoonshotRequestPayload
payload.model.should.equal("kimi-k2.6")
payload.temperature.should.equal(moonshotModels["kimi-k2.6"].temperature)
payload.max_tokens.should.equal(moonshotModels["kimi-k2.6"].maxTokens)
should(payload.temperature).equal(moonshotModels["kimi-k2.6"].temperature)
should(payload.max_tokens).equal(moonshotModels["kimi-k2.6"].maxTokens)
})
it("supports kimi-k3 request requirements and scopes reasoning replay", async () => {
const handler = new MoonshotHandler({
moonshotApiKey: "test-api-key",
apiModelId: "kimi-k3",
})
const createStub = sinon.stub().resolves(createAsyncIterable([]))
sinon.stub(handler as unknown as { ensureClient: () => unknown }, "ensureClient").returns({
chat: {
completions: {
create: createStub,
},
},
})
const messages: ClineStorageMessage[] = [
{ role: "user", content: "foreign-provider question" },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "Foreign reasoning", signature: "" },
{ type: "text", text: "Foreign answer" },
],
modelInfo: { providerId: "anthropic", modelId: "claude-sonnet-4", mode: "act" },
},
{ role: "user", content: "K3 question" },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "K3 reasoning", signature: "" },
{ type: "text", text: "K3 answer" },
],
modelInfo: { providerId: "moonshot", modelId: "kimi-k3", mode: "act" },
},
{ role: "user", content: "legacy question" },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "Unattributed reasoning", signature: "" },
{ type: "text", text: "Legacy answer" },
],
},
{ role: "user", content: "follow-up question" },
]
for await (const _chunk of handler.createMessage("system", messages)) {
// Consume stream to trigger request execution.
}
const payload = createStub.firstCall.args[0] as MoonshotRequestPayload
payload.model.should.equal("kimi-k3")
should(payload.max_completion_tokens).equal(moonshotModels["kimi-k3"].maxTokens)
should(payload.max_tokens).equal(undefined)
should(payload.temperature).equal(undefined)
const assistantReasoning = payload.messages
?.filter((message) => message.role === "assistant")
.map((message) => message.reasoning_content)
should(assistantReasoning).deepEqual([undefined, "K3 reasoning", undefined])
})
})
@@ -0,0 +1,104 @@
import "should"
import type OpenAI from "openai"
import sinon from "sinon"
import type { ApiStreamChunk } from "../../transform/stream"
import { OpenAiHandler } from "../openai"
describe("OpenAiHandler", () => {
afterEach(() => {
sinon.restore()
})
const createAsyncIterable = (data: unknown[] = []) => ({
[Symbol.asyncIterator]: async function* () {
yield* data
},
})
const createHandler = (chunks: unknown[]) => {
const handler = new OpenAiHandler({
openAiApiKey: "test-api-key",
openAiModelId: "test-model",
})
const fakeClient = {
chat: {
completions: {
create: sinon.stub().resolves(createAsyncIterable(chunks)),
},
},
}
sinon.stub(handler as unknown as { ensureClient: () => OpenAI }, "ensureClient").returns(fakeClient as unknown as OpenAI)
return handler
}
it("should emit the latest cumulative usage snapshot only once", async () => {
const handler = createHandler([
{
choices: [{ delta: { content: "O" } }],
usage: {
prompt_tokens: 48,
completion_tokens: 1,
total_tokens: 49,
prompt_tokens_details: { cached_tokens: 32 },
},
},
{
choices: [{ delta: { content: "K" } }],
usage: {
prompt_tokens: 48,
completion_tokens: 2,
total_tokens: 50,
prompt_tokens_details: { cached_tokens: 32 },
},
},
{ choices: [{ delta: {}, finish_reason: "stop" }], usage: null },
])
const chunks: ApiStreamChunk[] = []
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
chunks.push(chunk)
}
chunks.should.deepEqual([
{ type: "text", text: "O" },
{ type: "text", text: "K" },
{
type: "usage",
inputTokens: 48,
outputTokens: 2,
cacheReadTokens: 32,
cacheWriteTokens: 0,
},
])
})
it("should preserve providers that emit usage only on the final chunk", async () => {
const handler = createHandler([
{ choices: [{ delta: { content: "OK" } }] },
{
choices: [],
usage: {
prompt_tokens: 17,
completion_tokens: 9,
total_tokens: 26,
},
},
])
const chunks: ApiStreamChunk[] = []
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
chunks.push(chunk)
}
chunks.should.deepEqual([
{ type: "text", text: "OK" },
{
type: "usage",
inputTokens: 17,
outputTokens: 9,
cacheReadTokens: 0,
cacheWriteTokens: 0,
},
])
})
})
@@ -69,6 +69,7 @@ describe("SapAiCoreHandler", () => {
it("should support different Claude model variants", () => {
const modelVariants = [
"anthropic--claude-sonnet-5",
"anthropic--claude-4.6-sonnet",
"anthropic--claude-4-sonnet",
"anthropic--claude-4-opus",
@@ -134,9 +134,11 @@ interface ProviderChainOptions {
profile?: string
}
// a special jp inference profile was created for sonnet 4.6, opus 4.6, sonnet 4.5 & haiku 4.5
// a special jp inference profile was created for newer Claude CRIS models
// https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html
const JP_SUPPORTED_CRIS_MODELS = [
"anthropic.claude-sonnet-5",
"anthropic.claude-sonnet-5:1m",
"anthropic.claude-sonnet-4-6",
"anthropic.claude-sonnet-4-6:1m",
"anthropic.claude-opus-4-6-v1",
+10 -1
View File
@@ -1,7 +1,10 @@
import { DeepSeekModelId, deepSeekDefaultModelId, deepSeekModels, ModelInfo } from "@shared/api"
import { calculateApiCostOpenAI } from "@utils/cost"
import OpenAI from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import type {
ChatCompletionReasoningEffort,
ChatCompletionTool as OpenAITool,
} from "openai/resources/chat/completions"
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
import { ClineStorageMessage } from "@/shared/messages/content"
import { fetch } from "@/shared/net"
@@ -15,6 +18,7 @@ import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-p
interface DeepSeekHandlerOptions extends CommonApiHandlerOptions {
deepSeekApiKey?: string
apiModelId?: string
reasoningEffort?: string
}
export class DeepSeekHandler implements ApiHandler {
@@ -98,6 +102,11 @@ export class DeepSeekHandler implements ApiHandler {
stream_options: { include_usage: true },
// Only set temperature for non-thinking models
...(isDeepSeekThinkingModel ? {} : { temperature: 0 }),
// DeepSeek thinking models accept reasoning effort (low/medium map to high, xhigh maps to max).
// "none" isn't a valid DeepSeek value, so omit it and let the API use its default.
...(isDeepSeekThinkingModel && this.options.reasoningEffort && this.options.reasoningEffort !== "none"
? { reasoning_effort: this.options.reasoningEffort as ChatCompletionReasoningEffort }
: {}),
...getOpenAIToolParams(tools),
})
+6 -2
View File
@@ -8,7 +8,7 @@ import {
FunctionDeclaration as GoogleTool,
ThinkingLevel,
} from "@google/genai"
import { GeminiModelId, geminiDefaultModelId, geminiModels, ModelInfo } from "@shared/api"
import { GeminiModelId, geminiDefaultModelId, geminiModels, getVertexCustomModelInfo, ModelInfo } from "@shared/api"
import { GEMINI_FLASH_MAX_OUTPUT_TOKENS, isGeminiFlashModel } from "@utils/model-utils"
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
import { telemetryService } from "@/services/telemetry"
@@ -25,6 +25,7 @@ interface GeminiHandlerOptions extends CommonApiHandlerOptions {
isVertex?: boolean
vertexProjectId?: string
vertexRegion?: string
vertexCustomModelInfo?: ModelInfo
geminiApiKey?: string
geminiBaseUrl?: string
thinkingBudgetTokens?: number
@@ -464,12 +465,15 @@ export class GeminiHandler implements ApiHandler {
/**
* Get the model ID and info for the current configuration
*/
getModel(): { id: GeminiModelId; info: ModelInfo } {
getModel(): { id: string; info: ModelInfo } {
const modelId = this.options.apiModelId
if (modelId && modelId in geminiModels) {
const id = modelId as GeminiModelId
return { id, info: geminiModels[id] }
}
if (modelId && this.options.isVertex) {
return { id: modelId, info: getVertexCustomModelInfo(this.options.vertexCustomModelInfo) }
}
return {
id: geminiDefaultModelId,
info: geminiModels[geminiDefaultModelId],
+13 -3
View File
@@ -6,6 +6,7 @@ import { createOpenAIClient } from "@/shared/net"
import { ApiHandler, CommonApiHandlerOptions } from "../index"
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { addReasoningContent } from "../transform/r1-format"
import { ApiStream } from "../transform/stream"
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
@@ -48,16 +49,25 @@ export class MoonshotHandler implements ApiHandler {
const client = this.ensureClient()
const model = this.getModel()
const convertedMessages = convertToOpenAiMessages(messages)
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
...(model.id === "kimi-k3"
? addReasoningContent(convertedMessages, messages, {
includePreviousTurns: true,
shouldIncludeReasoning: (message) =>
message.modelInfo?.providerId === "moonshot" && message.modelInfo.modelId === "kimi-k3",
})
: convertedMessages),
]
const stream = await client.chat.completions.create({
model: model.id,
messages: openAiMessages,
temperature: model.info.temperature,
max_tokens: model.info.maxTokens,
// Kimi K3 fixes its sampling parameters and uses max_completion_tokens.
...(model.id === "kimi-k3"
? { max_completion_tokens: model.info.maxTokens }
: { max_tokens: model.info.maxTokens, temperature: model.info.temperature }),
stream: true,
stream_options: { include_usage: true },
...getOpenAIToolParams(tools),
+13 -8
View File
@@ -145,6 +145,7 @@ export class OpenAiHandler implements ApiHandler {
})
const toolCallProcessor = new ToolCallProcessor()
let latestUsage: OpenAI.Completions.CompletionUsage | undefined
for await (const chunk of stream) {
const delta = chunk.choices?.[0]?.delta
@@ -167,14 +168,18 @@ export class OpenAiHandler implements ApiHandler {
}
if (chunk.usage) {
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
// @ts-expect-error-next-line
cacheWriteTokens: chunk.usage.prompt_cache_miss_tokens || 0,
}
latestUsage = chunk.usage
}
}
if (latestUsage) {
yield {
type: "usage",
inputTokens: latestUsage.prompt_tokens || 0,
outputTokens: latestUsage.completion_tokens || 0,
cacheReadTokens: latestUsage.prompt_tokens_details?.cached_tokens || 0,
// @ts-expect-error-next-line
cacheWriteTokens: latestUsage.prompt_cache_miss_tokens || 0,
}
}
}
@@ -672,6 +672,7 @@ export class SapAiCoreHandler implements ApiHandler {
const anthropicModels = [
"anthropic--claude-4.5-haiku",
"anthropic--claude-sonnet-5",
"anthropic--claude-4.7-opus",
"anthropic--claude-4.6-opus",
"anthropic--claude-4.5-opus",
@@ -725,6 +726,7 @@ export class SapAiCoreHandler implements ApiHandler {
"amazon--nova-micro",
];
const converseStreamModels = [
"anthropic--claude-sonnet-5",
"anthropic--claude-4.7-opus",
"anthropic--claude-4.6-opus",
"anthropic--claude-4.5-opus",
+7 -3
View File
@@ -1,7 +1,7 @@
import { Tool as AnthropicTool } from "@anthropic-ai/sdk/resources/index"
import { AnthropicVertex } from "@anthropic-ai/vertex-sdk"
import { FunctionDeclaration as GoogleTool } from "@google/genai"
import { CLAUDE_SONNET_1M_SUFFIX, ModelInfo, VertexModelId, vertexDefaultModelId, vertexModels } from "@shared/api"
import { CLAUDE_SONNET_1M_SUFFIX, getVertexCustomModelInfo, ModelInfo, vertexDefaultModelId, vertexModels } from "@shared/api"
import { isClaudeOpusAdaptiveThinkingModel, resolveClaudeOpusAdaptiveThinking } from "@shared/utils/reasoning-support"
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
import { ClineStorageMessage } from "@/shared/messages/content"
@@ -16,6 +16,7 @@ interface VertexHandlerOptions extends CommonApiHandlerOptions {
vertexProjectId?: string
vertexRegion?: string
apiModelId?: string
vertexCustomModelInfo?: ModelInfo
thinkingBudgetTokens?: number
geminiApiKey?: string
geminiBaseUrl?: string
@@ -267,12 +268,15 @@ export class VertexHandler implements ApiHandler {
}
}
getModel(): { id: VertexModelId; info: ModelInfo } {
getModel(): { id: string; info: ModelInfo } {
const modelId = this.options.apiModelId
if (modelId && modelId in vertexModels) {
const id = modelId as VertexModelId
const id = modelId as keyof typeof vertexModels
return { id, info: vertexModels[id] }
}
if (modelId) {
return { id: modelId, info: getVertexCustomModelInfo(this.options.vertexCustomModelInfo) }
}
return {
id: vertexDefaultModelId,
info: vertexModels[vertexDefaultModelId],
@@ -78,6 +78,19 @@ describe("createOpenRouterStream", () => {
payload.should.not.have.property("max_tokens")
})
it("strips the custom Sonnet 5 1m suffix and pins Anthropic/Vertex routing", async () => {
const { client, create } = createClient()
await createOpenRouterStream(client as any, "system prompt", [{ role: "user", content: "hello" }] as any, {
id: "anthropic/claude-sonnet-5:1m",
info: createModelInfo(128_000),
})
const payload = create.firstCall.args[0] as any
payload.should.have.property("model", "anthropic/claude-sonnet-5")
payload.provider.should.deepEqual({ order: ["anthropic", "google-vertex/global"], allow_fallbacks: false })
})
it("adds cache_control blocks for Qwen models that require explicit OpenRouter caching", async () => {
for (const modelId of ["qwen/qwen3.6-plus", "qwen/qwen3.7-max"]) {
const { client, create } = createClient()
@@ -114,4 +127,87 @@ describe("createOpenRouterStream", () => {
should(payload.temperature).equal(undefined)
should(payload.top_p).equal(undefined)
})
it("includes reasoning for Claude budget-based reasoning models", async () => {
const { client, create } = createClient()
await createOpenRouterStream(
client as any,
"system prompt",
[{ role: "user", content: "hello" }] as any,
{
id: "anthropic/claude-sonnet-4.5",
info: createModelInfo(64_000),
},
undefined,
16_384,
)
const payload = create.firstCall.args[0] as any
payload.should.have.property("include_reasoning", true)
payload.reasoning.should.deepEqual({ max_tokens: 16_384 })
should(payload.temperature).equal(undefined)
})
it("sends reasoning effort instead of token budgets for supported OpenRouter/Cline model families", async () => {
for (const modelId of [
"zai/glm-5.2",
"z-ai/glm-5.2",
"moonshotai/kimi-k2-thinking",
"accounts/fireworks/models/minimax-m3",
"provider/mimo-vl",
"qwen/qwen3.7-max",
"deepseek/deepseek-r1",
]) {
const { client, create } = createClient()
await createOpenRouterStream(
client as any,
"system prompt",
[{ role: "user", content: "hello" }] as any,
{
id: modelId,
info: { ...createModelInfo(131_072), thinkingConfig: { maxBudget: 16_384 } },
},
"high",
16_384,
)
const payload = create.firstCall.args[0] as any
payload.should.have.property("include_reasoning", true)
payload.reasoning.should.deepEqual({ effort: "high" })
}
})
it("does not send reasoning effort for ClinePass requests when unset", async () => {
const { client, create } = createClient()
await createOpenRouterStream(client as any, "system prompt", [{ role: "user", content: "hello" }] as any, {
id: "cline-pass/glm-5.2",
info: createModelInfo(131_072),
})
const payload = create.firstCall.args[0] as any
payload.should.have.property("include_reasoning", true)
payload.should.not.have.property("reasoning")
})
it("sends the selected reasoning effort for ClinePass requests", async () => {
const { client, create } = createClient()
await createOpenRouterStream(
client as any,
"system prompt",
[{ role: "user", content: "hello" }] as any,
{
id: "cline-pass/glm-5.2",
info: createModelInfo(131_072),
},
"high",
)
const payload = create.firstCall.args[0] as any
payload.should.have.property("include_reasoning", true)
payload.reasoning.should.deepEqual({ effort: "high" })
})
})
@@ -4,10 +4,12 @@ import {
ModelInfo,
OPENROUTER_PROVIDER_PREFERENCES,
openRouterClaudeFable51mModelId,
openRouterClaudeOpus51mModelId,
openRouterClaudeOpus461mModelId,
openRouterClaudeOpus471mModelId,
openRouterClaudeOpus481mModelId,
openRouterClaudeSonnet41mModelId,
openRouterClaudeSonnet51mModelId,
openRouterClaudeSonnet451mModelId,
openRouterClaudeSonnet461mModelId,
} from "@shared/api"
@@ -62,9 +64,11 @@ export async function createOpenRouterStream(
model.id === openRouterClaudeSonnet41mModelId ||
model.id === openRouterClaudeSonnet451mModelId ||
model.id === openRouterClaudeSonnet461mModelId ||
model.id === openRouterClaudeSonnet51mModelId ||
model.id === openRouterClaudeOpus461mModelId ||
model.id === openRouterClaudeOpus471mModelId ||
model.id === openRouterClaudeOpus481mModelId ||
model.id === openRouterClaudeOpus51mModelId ||
model.id === openRouterClaudeFable51mModelId
if (isClaude1m) {
// remove the custom :1m suffix, to create the model id openrouter API expects
@@ -147,6 +151,8 @@ export async function createOpenRouterStream(
switch (model.id) {
case "anthropic/claude-haiku-4.5":
case "anthropic/claude-4.5-haiku":
case "anthropic/claude-sonnet-5":
case "anthropic/claude-5-sonnet":
case "anthropic/claude-sonnet-4.6":
case "anthropic/claude-4.6-sonnet":
case "anthropic/claude-sonnet-4.5":
@@ -14,14 +14,20 @@ export type DeepSeekReasonerMessage = OpenAI.Chat.ChatCompletionMessageParam & {
};
/**
* Adds reasoning_content to OpenAI messages for DeepSeek Reasoner.
* Per DeepSeek API: reasoning_content should be passed back during tool calling in the same turn,
* and omitted when starting a new turn.
* Adds reasoning_content to OpenAI-compatible messages.
* DeepSeek only needs it during tool calling in the current turn. Providers such as Moonshot K3
* can include previous turns and filter reasoning by its originating message.
*/
export function addReasoningContent(
openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[],
originalMessages: ClineStorageMessage[],
options: {
includePreviousTurns?: boolean;
shouldIncludeReasoning?: (message: ClineStorageMessage) => boolean;
} = {},
): DeepSeekReasonerMessage[] {
const { includePreviousTurns = false, shouldIncludeReasoning = () => true } = options;
// Find last user message index (start of current turn)
// If no user message exists (lastUserIndex = -1), all messages are in the "current turn",
// so reasoning_content will be added to all assistant messages. This is intentional.
@@ -38,7 +44,7 @@ export function addReasoningContent(
let assistantIdx = 0;
for (const msg of originalMessages) {
if (msg.role === "assistant") {
if (Array.isArray(msg.content)) {
if (Array.isArray(msg.content) && shouldIncludeReasoning(msg)) {
const thinking = msg.content
.filter(
(p): p is ClineAssistantThinkingBlock => p.type === "thinking",
@@ -53,12 +59,12 @@ export function addReasoningContent(
}
}
// Add reasoning_content only to assistant messages in current turn
// Add reasoning_content to assistant messages in the requested scope.
let aiIdx = 0;
return openAiMessages.map((msg, i): DeepSeekReasonerMessage => {
if (msg.role === "assistant") {
const thinking = thinkingByIndex.get(aiIdx++);
if (thinking && i >= lastUserIndex) {
if (thinking && (includePreviousTurns || i >= lastUserIndex)) {
return { ...msg, reasoning_content: thinking };
}
}
@@ -3,10 +3,12 @@ import {
CLAUDE_SONNET_1M_SUFFIX,
ModelInfo,
openRouterClaudeFable51mModelId,
openRouterClaudeOpus51mModelId,
openRouterClaudeOpus461mModelId,
openRouterClaudeOpus471mModelId,
openRouterClaudeOpus481mModelId,
openRouterClaudeSonnet41mModelId,
openRouterClaudeSonnet51mModelId,
openRouterClaudeSonnet451mModelId,
openRouterClaudeSonnet461mModelId,
} from "@shared/api"
@@ -38,9 +40,11 @@ export async function createVercelAIGatewayStream(
model.id === openRouterClaudeSonnet41mModelId ||
model.id === openRouterClaudeSonnet451mModelId ||
model.id === openRouterClaudeSonnet461mModelId ||
model.id === openRouterClaudeSonnet51mModelId ||
model.id === openRouterClaudeOpus461mModelId ||
model.id === openRouterClaudeOpus471mModelId ||
model.id === openRouterClaudeOpus481mModelId ||
model.id === openRouterClaudeOpus51mModelId ||
model.id === openRouterClaudeFable51mModelId
if (isClaude1m) {
// remove the custom :1m suffix, to create the model id the API expects
@@ -1,14 +1,14 @@
import * as yaml from "js-yaml"
import * as yaml from "js-yaml";
export type FrontmatterParseResult = {
data: Record<string, unknown>
data: Record<string, unknown>;
/**
* The markdown content after stripping the `--- frontmatter ---` block.
*
* Named `body` (rather than `content`) to make it clear this is the remaining
* document body and to keep this helper generic for multiple consumers.
*/
body: string
body: string;
/**
* True when the input contained a frontmatter block, even if parsing failed.
@@ -17,15 +17,15 @@ export type FrontmatterParseResult = {
* - "no frontmatter provided" (baseline behavior), vs
* - "frontmatter was provided" (may have semantic meaning in future consumers).
*/
hadFrontmatter: boolean
hadFrontmatter: boolean;
/**
* Present only when YAML frontmatter was detected but failed to parse.
*
* This helper is intentionally fail-open and does not log. Returning `parseError`
* lets each caller decide whether to log, surface diagnostics, etc.
*/
parseError?: string
}
parseError?: string;
};
/**
* Parse YAML frontmatter from markdown content.
@@ -35,19 +35,32 @@ export type FrontmatterParseResult = {
* - If no frontmatter exists, returns data={} and body=original markdown.
*/
export function parseYamlFrontmatter(markdown: string): FrontmatterParseResult {
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/
const match = markdown.match(frontmatterRegex)
if (markdown.charCodeAt(0) === 0xfeff) {
markdown = markdown.slice(1); // Remove BOM format if present
}
const frontmatterRegex = /^\s*---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
const match = markdown.match(frontmatterRegex);
if (!match) {
return { data: {}, body: markdown, hadFrontmatter: false }
return { data: {}, body: markdown, hadFrontmatter: false };
}
const [, yamlContent, body] = match
const [, yamlContent, body] = match;
try {
const data = (yaml.load(yamlContent, { schema: yaml.JSON_SCHEMA }) as Record<string, unknown>) || {}
return { data, body, hadFrontmatter: true }
const data =
(yaml.load(yamlContent, { schema: yaml.JSON_SCHEMA }) as Record<
string,
unknown
>) || {};
return { data, body, hadFrontmatter: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { data: {}, body: markdown, hadFrontmatter: true, parseError: message }
const message = error instanceof Error ? error.message : String(error);
return {
data: {},
body: markdown,
hadFrontmatter: true,
parseError: message,
};
}
}
+3 -3
View File
@@ -11,6 +11,7 @@ import { McpHub } from "@services/mcp/McpHub"
import type { ApiProvider, ModelInfo } from "@shared/api"
import type { ChatContent } from "@shared/ChatContent"
import type { ExtensionState, Platform } from "@shared/ExtensionMessage"
import { DEFAULT_FOCUS_CHAIN_SETTINGS } from "@shared/FocusChainSettings"
import type { HistoryItem } from "@shared/HistoryItem"
import type { McpMarketplaceCatalog, McpMarketplaceItem } from "@shared/mcp"
import { type Settings } from "@shared/storage/state-keys"
@@ -523,8 +524,7 @@ export class Controller {
// On login we route the user to the managed "cline" provider, but preserve a
// "cline-pass" selection made during onboarding (otherwise it would be clobbered).
// A "cline-pass" provider can only be set when the ext-cline-pass flag is on, so
// non-ClinePass logins are unaffected.
// Non-ClinePass logins are unaffected because they do not persist "cline-pass".
const planProvider: ApiProvider =
currentApiConfiguration.planModeApiProvider === "cline-pass" ? "cline-pass" : "cline"
const actProvider: ApiProvider = currentApiConfiguration.actModeApiProvider === "cline-pass" ? "cline-pass" : "cline"
@@ -856,7 +856,7 @@ export class Controller {
const taskHistory = this.stateManager.getGlobalStateKey("taskHistory")
const autoApprovalSettings = this.stateManager.getGlobalSettingsKey("autoApprovalSettings")
const browserSettings = this.stateManager.getGlobalSettingsKey("browserSettings")
const focusChainSettings = this.stateManager.getGlobalSettingsKey("focusChainSettings")
const focusChainSettings = this.stateManager.getGlobalSettingsKey("focusChainSettings") ?? DEFAULT_FOCUS_CHAIN_SETTINGS
const preferredLanguage = this.stateManager.getGlobalSettingsKey("preferredLanguage")
const mode = this.stateManager.getGlobalSettingsKey("mode")
const strictPlanModeEnabled = this.stateManager.getGlobalSettingsKey("strictPlanModeEnabled")
@@ -25,7 +25,7 @@ describe("clearOrganizationForClinePassProviderSelection", () => {
} as unknown as Controller;
}
it("does nothing when Cline Pass is not selected", async () => {
it("does nothing when ClinePass 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 Cline Pass is selected", async () => {
it("switches to the personal account when ClinePass is selected", async () => {
await clearOrganizationForClinePassProviderSelection(createController(), {
planModeApiProvider: "cline-pass",
actModeApiProvider: "openrouter",
@@ -1,5 +1,5 @@
import * as disk from "@core/storage/disk"
import { openRouterClaudeFable51mModelId } from "@shared/api"
import { openRouterClaudeFable51mModelId, openRouterClaudeOpus51mModelId } from "@shared/api"
import axios from "axios"
import { expect } from "chai"
import fs from "fs/promises"
@@ -135,4 +135,131 @@ describe("refreshClineModels", () => {
expect(fable1m.contextWindow).to.equal(1_000_000)
expect(fable1m.tiers).to.not.equal(undefined)
})
it("adds Claude Opus 5 context variants to the Cline model list", async () => {
sandbox.stub(getFeatureFlagsService(), "getBooleanFlagEnabled").callsFake((flag) => {
return flag === FeatureFlag.EXTENSION_CLINE_MODELS_ENDPOINT
})
sandbox.stub(ClineEnv, "config").returns({
environment: Environment.production,
appBaseUrl: "https://app.cline-mock.bot",
apiBaseUrl: "https://api.cline-mock.bot",
mcpBaseUrl: "https://api.cline-mock.bot/v1/mcp",
})
sandbox.stub(StateManager, "get").returns({
getModelsCache: () => null,
setModelsCache: () => {},
} as unknown as StateManager)
sandbox.stub(disk, "ensureCacheDirectoryExists").resolves("/tmp")
sandbox.stub(fs, "writeFile").resolves()
sandbox.stub(axios, "get").resolves({
data: {
data: [
{
id: "anthropic/claude-opus-5",
name: "Claude Opus 5",
description: "Fetched description",
context_length: 1_000_000,
top_provider: {
max_completion_tokens: 128_000,
context_length: 1_000_000,
is_moderated: false,
},
architecture: {
modality: ["text", "image"],
},
pricing: {
prompt: "0.000005",
completion: "0.000025",
input_cache_read: "0.0000005",
input_cache_write: "0.00000625",
},
supported_parameters: ["include_reasoning", "reasoning"],
},
],
},
})
const models = await refreshClineModels({} as Controller)
const opus5 = models["anthropic/claude-opus-5"]
const opus51m = models[openRouterClaudeOpus51mModelId]
expect(opus5.contextWindow).to.equal(200_000)
expect(opus5.maxTokens).to.equal(128_000)
expect(opus5.supportsPromptCache).to.equal(true)
expect(opus5.inputPrice).to.equal(5)
expect(opus5.outputPrice).to.equal(25)
expect(opus5.cacheWritesPrice).to.equal(6.25)
expect(opus5.cacheReadsPrice).to.equal(0.5)
expect(opus51m.contextWindow).to.equal(1_000_000)
expect(opus51m.tiers).to.not.equal(undefined)
})
it("prefers Vercel-style Z.ai IDs when the Cline model list includes OpenRouter aliases", async () => {
sandbox.stub(getFeatureFlagsService(), "getBooleanFlagEnabled").callsFake((flag) => {
return flag === FeatureFlag.EXTENSION_CLINE_MODELS_ENDPOINT
})
sandbox.stub(ClineEnv, "config").returns({
environment: Environment.production,
appBaseUrl: "https://app.cline-mock.bot",
apiBaseUrl: "https://api.cline-mock.bot",
mcpBaseUrl: "https://api.cline-mock.bot/v1/mcp",
})
sandbox.stub(StateManager, "get").returns({
getModelsCache: () => null,
setModelsCache: () => {},
} as unknown as StateManager)
sandbox.stub(disk, "ensureCacheDirectoryExists").resolves("/tmp")
sandbox.stub(fs, "writeFile").resolves()
sandbox.stub(axios, "get").resolves({
data: {
data: [
{
id: "z-ai/glm-5.2",
name: "OpenRouter GLM 5.2",
description: "OpenRouter alias",
context_length: 128_000,
top_provider: {
max_completion_tokens: 8_192,
context_length: 128_000,
is_moderated: false,
},
architecture: {
modality: "text->text",
},
pricing: {
prompt: "0.00000098",
completion: "0.00000308",
},
supported_parameters: ["include_reasoning", "reasoning"],
},
{
id: "zai/glm-5.2",
name: "GLM 5.2",
description: "Vercel canonical ID",
context_length: 1_000_000,
top_provider: {
max_completion_tokens: 131_072,
context_length: 1_000_000,
is_moderated: false,
},
architecture: {
modality: "text->text",
},
pricing: {
prompt: "0.0000015",
completion: "0.0000045",
},
supported_parameters: ["include_reasoning", "reasoning"],
},
],
},
})
const models = await refreshClineModels({} as Controller)
expect(models["zai/glm-5.2"]).to.not.equal(undefined)
expect(models["zai/glm-5.2"].contextWindow).to.equal(1_000_000)
expect(models["z-ai/glm-5.2"]).to.equal(undefined)
})
})
@@ -39,7 +39,7 @@ describe("refreshClineRecommendedModels", () => {
data: {
recommended: [
{
id: "anthropic/claude-sonnet-4.6",
id: "anthropic/claude-sonnet-5",
description: "Remote recommended",
tags: ["NEW"],
},
@@ -48,7 +48,7 @@ describe("refreshClineRecommendedModels", () => {
clinePass: [
{
id: "cline-pass/glm-5",
description: "Remote Cline Pass",
description: "Remote ClinePass",
tags: ["CLINE_PASS"],
},
],
@@ -61,8 +61,8 @@ describe("refreshClineRecommendedModels", () => {
expect(result).to.deep.equal({
recommended: [
{
id: "anthropic/claude-sonnet-4.6",
name: "anthropic/claude-sonnet-4.6",
id: "anthropic/claude-sonnet-5",
name: "anthropic/claude-sonnet-5",
description: "Remote recommended",
tags: ["NEW"],
},
@@ -79,7 +79,7 @@ describe("refreshClineRecommendedModels", () => {
{
id: "cline-pass/glm-5",
name: "cline-pass/glm-5",
description: "Remote Cline Pass",
description: "Remote ClinePass",
tags: ["CLINE_PASS"],
},
],
@@ -114,7 +114,7 @@ describe("refreshClineRecommendedModels", () => {
clinePass: [
{
id: "cline-pass/glm-5",
description: "Remote Cline Pass",
description: "Remote ClinePass",
tags: ["CLINE_PASS"],
},
],
@@ -127,4 +127,99 @@ describe("refreshClineRecommendedModels", () => {
expect(axiosGetStub.calledOnce).to.equal(true);
expect(secondResult).to.deep.equal(firstResult);
});
it("normalizes Cline provider Z.ai recommended IDs to the Cline API alias", async () => {
sandbox.stub(ClineEnv, "config").returns({
environment: Environment.production,
appBaseUrl: "https://app.cline-mock.bot",
apiBaseUrl: "https://api.cline-mock.bot",
mcpBaseUrl: "https://api.cline-mock.bot/v1/mcp",
});
sandbox.stub(disk, "ensureCacheDirectoryExists").resolves("/tmp");
sandbox.stub(fs, "writeFile").resolves();
sandbox.stub(axios, "get").resolves({
data: {
recommended: [
{
id: "zai/glm-5.2",
name: "zai/glm-5.2",
description: "Recommended GLM",
},
],
free: [
{
id: "zai/free-glm",
description: "Free GLM",
},
],
},
});
const result = await refreshClineRecommendedModels();
expect(result.recommended[0]).to.include({
id: "z-ai/glm-5.2",
name: "z-ai/glm-5.2",
});
expect(result.free[0]).to.include({
id: "z-ai/free-glm",
name: "z-ai/free-glm",
});
});
it("normalizes cached Cline provider Z.ai recommended IDs", async () => {
sandbox.stub(ClineEnv, "config").returns({
environment: Environment.production,
appBaseUrl: "https://app.cline-mock.bot",
apiBaseUrl: "https://api.cline-mock.bot",
mcpBaseUrl: "https://api.cline-mock.bot/v1/mcp",
});
sandbox.stub(disk, "ensureCacheDirectoryExists").resolves("/tmp");
sandbox.stub(axios, "get").rejects(new Error("network unavailable"));
sandbox.stub(fs, "access").resolves();
sandbox.stub(fs, "readFile").resolves(
JSON.stringify({
recommended: [
{
id: "zai/glm-5.2",
name: "zai/glm-5.2",
},
],
}),
);
const result = await refreshClineRecommendedModels();
expect(result.recommended.map((model) => model.id)).to.deep.equal(["z-ai/glm-5.2"]);
expect(result.recommended.map((model) => model.name)).to.deep.equal(["z-ai/glm-5.2"]);
});
it("prefers canonical ClinePass Z.ai IDs when aliases are also present", async () => {
sandbox.stub(ClineEnv, "config").returns({
environment: Environment.production,
appBaseUrl: "https://app.cline-mock.bot",
apiBaseUrl: "https://api.cline-mock.bot",
mcpBaseUrl: "https://api.cline-mock.bot/v1/mcp",
});
sandbox.stub(disk, "ensureCacheDirectoryExists").resolves("/tmp");
sandbox.stub(fs, "writeFile").resolves();
sandbox.stub(axios, "get").resolves({
data: {
clinePass: [
{
id: "cline-pass/z-ai/glm-5.2",
description: "OpenRouter alias",
},
{
id: "cline-pass/zai/glm-5.2",
description: "Canonical ID",
},
],
},
});
const result = await refreshClineRecommendedModels();
expect(result.clinePass.map((model) => model.id)).to.deep.equal(["cline-pass/zai/glm-5.2"]);
});
});
@@ -5,7 +5,7 @@ import type { Controller } from "../index"
export const CLINE_PASS_PROVIDER_ID = "cline-pass"
/**
* Cline Pass always uses the user's personal Cline account balance.
* ClinePass 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(null)
} catch (error) {
Logger.debug("Failed to switch Cline Pass to personal account", { error })
Logger.debug("Failed to switch ClinePass to personal account", { error })
}
}
@@ -15,10 +15,12 @@ import {
CLAUDE_OPUS_1M_TIERS,
CLAUDE_SONNET_1M_TIERS,
openRouterClaudeFable51mModelId,
openRouterClaudeOpus51mModelId,
openRouterClaudeOpus461mModelId,
openRouterClaudeOpus471mModelId,
openRouterClaudeOpus481mModelId,
openRouterClaudeSonnet41mModelId,
openRouterClaudeSonnet51mModelId,
openRouterClaudeSonnet451mModelId,
openRouterClaudeSonnet461mModelId,
} from "@/shared/api"
@@ -87,6 +89,37 @@ interface ClineRawModelInfo {
// Track pending refresh promise to prevent duplicate concurrent fetches
let pendingRefresh: Promise<Record<string, ModelInfo>> | null = null
interface ModelIdAliasRule {
canonicalPrefix: string
aliasPrefix: string
}
// Mirrors @cline/llms VERCEL_OPENROUTER_MODEL_ID_ALIAS_RULES.
const VERCEL_OPENROUTER_MODEL_ID_ALIAS_RULES = [
{ canonicalPrefix: "zai/", aliasPrefix: "z-ai/" },
] as const satisfies readonly ModelIdAliasRule[]
function preferCanonicalModelIds<T>(models: Record<string, T>, rules: readonly ModelIdAliasRule[]): Record<string, T> {
return Object.fromEntries(
Object.entries(models).filter(([modelId]) => {
for (const rule of rules) {
if (!modelId.startsWith(rule.aliasPrefix)) {
continue
}
const canonicalModelId = `${rule.canonicalPrefix}${modelId.slice(rule.aliasPrefix.length)}`
if (canonicalModelId in models) {
return false
}
}
return true
}),
)
}
function preferClineCanonicalModelIds(models: Record<string, ModelInfo>): Record<string, ModelInfo> {
return preferCanonicalModelIds(models, VERCEL_OPENROUTER_MODEL_ID_ALIAS_RULES)
}
async function fetchRawClineModels(): Promise<ClineRawModelInfo[]> {
const apiBaseUrl = ClineEnv.config().apiBaseUrl
const response = await axios.get(`${apiBaseUrl}/api/v1/ai/cline/models`, getAxiosSettings())
@@ -107,7 +140,7 @@ async function fetchRawClineModels(): Promise<ClineRawModelInfo[]> {
export async function refreshClineModels(controller: Controller): Promise<Record<string, ModelInfo>> {
const shouldUseClineEndpointSource = featureFlagsService.getBooleanFlagEnabled(FeatureFlag.EXTENSION_CLINE_MODELS_ENDPOINT)
if (!shouldUseClineEndpointSource) {
return refreshOpenRouterModels(controller)
return preferClineCanonicalModelIds(await refreshOpenRouterModels(controller))
}
// Check in-memory cache first
@@ -181,6 +214,15 @@ async function fetchAndCacheClineModels(): Promise<Record<string, ModelInfo>> {
// Apply model-specific overrides for known models
switch (rawModel.id) {
case "anthropic/claude-sonnet-5":
case "anthropic/claude-5-sonnet":
modelInfo.contextWindow = 200_000
modelInfo.supportsPromptCache = true
modelInfo.inputPrice = 2.0
modelInfo.outputPrice = 10.0
modelInfo.cacheWritesPrice = 2.5
modelInfo.cacheReadsPrice = 0.2
break
case "anthropic/claude-sonnet-4.6":
case "anthropic/claude-4.6-sonnet":
case "anthropic/claude-sonnet-4.5":
@@ -198,6 +240,15 @@ async function fetchAndCacheClineModels(): Promise<Record<string, ModelInfo>> {
modelInfo.cacheWritesPrice = 3.75
modelInfo.cacheReadsPrice = 0.3
break
case "anthropic/claude-opus-5":
case "anthropic/claude-5-opus":
modelInfo.contextWindow = 200_000
modelInfo.supportsPromptCache = true
modelInfo.inputPrice = 5.0
modelInfo.outputPrice = 25.0
modelInfo.cacheWritesPrice = 6.25
modelInfo.cacheReadsPrice = 0.5
break
case "anthropic/claude-opus-4.6":
case "anthropic/claude-opus-4.7":
case "anthropic/claude-opus-4.8":
@@ -273,12 +324,17 @@ async function fetchAndCacheClineModels(): Promise<Record<string, ModelInfo>> {
rawModel.id === "anthropic/claude-sonnet-4" ||
rawModel.id === "anthropic/claude-sonnet-4.5" ||
rawModel.id === "anthropic/claude-sonnet-4.6" ||
rawModel.id === "anthropic/claude-4.6-sonnet"
rawModel.id === "anthropic/claude-4.6-sonnet" ||
rawModel.id === "anthropic/claude-sonnet-5" ||
rawModel.id === "anthropic/claude-5-sonnet"
) {
const claudeSonnet1mModelInfo = cloneDeep(modelInfo)
claudeSonnet1mModelInfo.contextWindow = 1_000_000
claudeSonnet1mModelInfo.tiers = CLAUDE_SONNET_1M_TIERS
if (rawModel.id === "anthropic/claude-sonnet-5" || rawModel.id === "anthropic/claude-5-sonnet") {
models[openRouterClaudeSonnet51mModelId] = claudeSonnet1mModelInfo
}
if (rawModel.id === "anthropic/claude-sonnet-4") {
models[openRouterClaudeSonnet41mModelId] = claudeSonnet1mModelInfo
}
@@ -309,6 +365,12 @@ async function fetchAndCacheClineModels(): Promise<Record<string, ModelInfo>> {
models[openRouterClaudeOpus481mModelId] = claudeOpus1mModelInfo
}
}
if (rawModel.id === "anthropic/claude-opus-5" || rawModel.id === "anthropic/claude-5-opus") {
const claudeOpus51mModelInfo = cloneDeep(modelInfo)
claudeOpus51mModelInfo.contextWindow = 1_000_000
claudeOpus51mModelInfo.tiers = CLAUDE_OPUS_1M_TIERS
models[openRouterClaudeOpus51mModelId] = claudeOpus51mModelInfo
}
if (rawModel.id === "anthropic/claude-fable-5") {
const claudeFable1mModelInfo = cloneDeep(modelInfo)
claudeFable1mModelInfo.contextWindow = 1_000_000
@@ -319,8 +381,9 @@ async function fetchAndCacheClineModels(): Promise<Record<string, ModelInfo>> {
if (Object.keys(models).length === 0) {
throw new Error("No Cline models returned from API")
}
// Save models and cache them in memory
await fs.writeFile(clineModelsFilePath, JSON.stringify(models))
await fs.writeFile(clineModelsFilePath, JSON.stringify(preferClineCanonicalModelIds(models)))
Logger.log("Cline models fetched and saved")
} catch (error) {
Logger.error("Error fetching Cline models:", error)
@@ -340,6 +403,7 @@ async function fetchAndCacheClineModels(): Promise<Record<string, ModelInfo>> {
// Avoid poisoning in-memory cache with an empty model map after transient failures.
if (Object.keys(models).length > 0) {
models = preferClineCanonicalModelIds(models)
StateManager.get().setModelsCache("cline", models)
}
@@ -23,6 +23,31 @@ export interface ClineRecommendedModelsData {
}
const RECOMMENDED_MODELS_CACHE_TTL_MS = 60 * 60 * 1000;
const CLINE_PASS_MODEL_ID_ALIAS_RULES = [
{ canonicalPrefix: "cline-pass/zai/", aliasPrefix: "cline-pass/z-ai/" },
] as const;
function normalizeClineProviderRecommendedModelId(modelId: string): string {
const zaiPrefix = "zai/";
return modelId.startsWith(zaiPrefix) ? `z-ai/${modelId.slice(zaiPrefix.length)}` : modelId;
}
function normalizeClineProviderRecommendedModels(
models: ClineRecommendedModelData[],
): ClineRecommendedModelData[] {
return models.map((model) => {
const id = normalizeClineProviderRecommendedModelId(model.id);
if (id === model.id) {
return model;
}
return {
...model,
id,
name: model.name === model.id ? id : model.name,
};
});
}
let pendingRefresh: Promise<ClineRecommendedModelsData> | null = null;
let inMemoryCache: {
@@ -30,6 +55,26 @@ let inMemoryCache: {
timestamp: number;
} | null = null;
function preferCanonicalRecommendedModels(
models: ClineRecommendedModelData[],
): ClineRecommendedModelData[] {
const modelIds = new Set(models.map((model) => model.id));
return models.filter((model) => {
for (const rule of CLINE_PASS_MODEL_ID_ALIAS_RULES) {
if (!model.id.startsWith(rule.aliasPrefix)) {
continue;
}
const canonicalModelId = `${rule.canonicalPrefix}${model.id.slice(rule.aliasPrefix.length)}`;
if (modelIds.has(canonicalModelId)) {
return false;
}
}
return true;
});
}
function normalizeRecommendedModel(
raw: unknown,
): ClineRecommendedModelData | null {
@@ -89,7 +134,11 @@ function normalizeRecommendedModelsResponse(
.map((model) => normalizeRecommendedModel(model))
.filter((model): model is ClineRecommendedModelData => model !== null);
return { recommended, free, clinePass };
return {
recommended: normalizeClineProviderRecommendedModels(recommended),
free: normalizeClineProviderRecommendedModels(free),
clinePass: preferCanonicalRecommendedModels(clinePass),
};
}
export async function refreshClineRecommendedModels(): Promise<ClineRecommendedModelsData> {
@@ -161,14 +210,9 @@ async function fetchAndCacheClineRecommendedModels(): Promise<ClineRecommendedMo
"utf8",
);
const parsed = JSON.parse(fileContents);
if (parsed) {
result = {
recommended: Array.isArray(parsed.recommended)
? parsed.recommended
: [],
free: Array.isArray(parsed.free) ? parsed.free : [],
clinePass: Array.isArray(parsed.clinePass) ? parsed.clinePass : [],
};
const normalized = normalizeRecommendedModelsResponse(parsed);
if (normalized) {
result = normalized;
Logger.log("Loaded Cline recommended models from cache");
}
}
@@ -12,10 +12,12 @@ import {
CLAUDE_OPUS_1M_TIERS,
CLAUDE_SONNET_1M_TIERS,
openRouterClaudeFable51mModelId,
openRouterClaudeOpus51mModelId,
openRouterClaudeOpus461mModelId,
openRouterClaudeOpus471mModelId,
openRouterClaudeOpus481mModelId,
openRouterClaudeSonnet41mModelId,
openRouterClaudeSonnet51mModelId,
openRouterClaudeSonnet451mModelId,
openRouterClaudeSonnet461mModelId,
} from "@/shared/api"
@@ -150,6 +152,16 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
}
switch (rawModel.id) {
case "anthropic/claude-sonnet-5":
case "anthropic/claude-5-sonnet":
// NOTE: we artificially restrict the context window to 200k to keep costs low for users, and have a :1m model variant created below for users that want to use the full 1m.
modelInfo.contextWindow = 200_000
modelInfo.supportsPromptCache = true
modelInfo.inputPrice = 2.0
modelInfo.outputPrice = 10.0
modelInfo.cacheWritesPrice = 2.5
modelInfo.cacheReadsPrice = 0.2
break
case "anthropic/claude-sonnet-4.6":
case "anthropic/claude-4.6-sonnet":
case "anthropic/claude-sonnet-4.5":
@@ -173,6 +185,15 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
modelInfo.cacheWritesPrice = 3.75
modelInfo.cacheReadsPrice = 0.3
break
case "anthropic/claude-opus-5":
case "anthropic/claude-5-opus":
modelInfo.contextWindow = 200_000 // restrict to 200k, 1m variant created below
modelInfo.supportsPromptCache = true
modelInfo.inputPrice = 5.0
modelInfo.outputPrice = 25.0
modelInfo.cacheWritesPrice = 6.25
modelInfo.cacheReadsPrice = 0.5
break
case "anthropic/claude-opus-4.6":
case "anthropic/claude-opus-4.7":
case "anthropic/claude-opus-4.8":
@@ -294,11 +315,17 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
rawModel.id === "anthropic/claude-sonnet-4.5" ||
rawModel.id === "anthropic/claude-4.5-sonnet" ||
rawModel.id === "anthropic/claude-sonnet-4.6" ||
rawModel.id === "anthropic/claude-4.6-sonnet"
rawModel.id === "anthropic/claude-4.6-sonnet" ||
rawModel.id === "anthropic/claude-sonnet-5" ||
rawModel.id === "anthropic/claude-5-sonnet"
) {
const claudeSonnet1mModelInfo = cloneDeep(modelInfo)
claudeSonnet1mModelInfo.contextWindow = 1_000_000 // limiting providers to those that support 1m context window
claudeSonnet1mModelInfo.tiers = CLAUDE_SONNET_1M_TIERS
// sonnet 5
if (rawModel.id === "anthropic/claude-sonnet-5" || rawModel.id === "anthropic/claude-5-sonnet") {
models[openRouterClaudeSonnet51mModelId] = claudeSonnet1mModelInfo
}
// sonnet 4
if (rawModel.id === "anthropic/claude-sonnet-4") {
models[openRouterClaudeSonnet41mModelId] = claudeSonnet1mModelInfo
@@ -332,6 +359,12 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
models[openRouterClaudeOpus481mModelId] = claudeOpus1mModelInfo
}
}
if (rawModel.id === "anthropic/claude-opus-5" || rawModel.id === "anthropic/claude-5-opus") {
const claudeOpus51mModelInfo = cloneDeep(modelInfo)
claudeOpus51mModelInfo.contextWindow = 1_000_000
claudeOpus51mModelInfo.tiers = CLAUDE_OPUS_1M_TIERS
models[openRouterClaudeOpus51mModelId] = claudeOpus51mModelInfo
}
if (rawModel.id === "anthropic/claude-fable-5") {
const claudeFable1mModelInfo = cloneDeep(modelInfo)
claudeFable1mModelInfo.contextWindow = 1_000_000
@@ -109,6 +109,12 @@ export async function updateApiConfigurationProto(
protoApiConfiguration.planModeAihubmixModelInfo,
)
: undefined,
planModeVertexCustomModelInfo:
protoApiConfiguration.planModeVertexCustomModelInfo
? fromProtobufModelInfo(
protoApiConfiguration.planModeVertexCustomModelInfo,
)
: undefined,
// Act Mode
actModeOpenRouterModelInfo:
@@ -168,6 +174,12 @@ export async function updateApiConfigurationProto(
protoApiConfiguration.actModeAihubmixModelInfo,
)
: undefined,
actModeVertexCustomModelInfo:
protoApiConfiguration.actModeVertexCustomModelInfo
? fromProtobufModelInfo(
protoApiConfiguration.actModeVertexCustomModelInfo,
)
: undefined,
geminiPlanModeThinkingLevel:
protoApiConfiguration.geminiPlanModeThinkingLevel,
geminiActModeThinkingLevel:
@@ -19,19 +19,16 @@ describe("Hook System", () => {
await writeHookScriptForPlatform(hookPath, nodeScript)
}
beforeEach(async () => {
beforeEach(async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_TEST_TIMEOUT_MS)
}
setDistinctId("test-id")
hookTestEnv = await createHookTestEnv()
tempDir = hookTestEnv.tempDir
sandbox = hookTestEnv.sandbox
})
beforeEach(function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_TEST_TIMEOUT_MS)
}
})
afterEach(async () => {
await hookTestEnv.cleanup()
})
@@ -6,7 +6,7 @@ import { PromptBuilder } from "../registry/PromptBuilder"
import { SystemPromptSection } from "../templates/placeholders"
import type { ComponentRegistry, PromptVariant, SystemPromptContext } from "../types"
import { createVariant } from "../variants/variant-builder"
import { mockProviderInfo } from "./integration.test"
import { mockProviderInfo } from "./test-fixtures"
describe("PromptBuilder", () => {
const mockContext: SystemPromptContext = {
@@ -3,7 +3,7 @@ import type { McpHub } from "@/services/mcp/McpHub"
import { ModelFamily } from "@/shared/prompts"
import { PromptRegistry } from "../registry/PromptRegistry"
import type { SystemPromptContext } from "../types"
import { mockProviderInfo } from "./integration.test"
import { mockProviderInfo } from "./test-fixtures"
describe("PromptRegistry", () => {
let registry: PromptRegistry
@@ -2,7 +2,7 @@ import { expect } from "chai"
import type { McpHub } from "@/services/mcp/McpHub"
import { TemplateEngine } from "../templates/TemplateEngine"
import type { SystemPromptContext } from "../types"
import { mockProviderInfo } from "./integration.test"
import { mockProviderInfo } from "./test-fixtures"
describe("TemplateEngine", () => {
let templateEngine: TemplateEngine
@@ -25,6 +25,7 @@ import type { McpHub } from "@/services/mcp/McpHub"
import { ModelFamily } from "@/shared/prompts"
import { getSystemPrompt } from "../index"
import type { SystemPromptContext } from "../types"
import { mockProviderInfo } from "./test-fixtures"
// ============================================================================
// Configuration
@@ -111,12 +112,6 @@ async function assertSnapshot(name: string, content: string): Promise<void> {
// Test Context Helpers
// ============================================================================
export const mockProviderInfo = {
providerId: "test",
model: { id: "fast", info: { supportsPromptCache: false } },
mode: "act" as const,
}
const makeProviderInfo = (modelId: string, providerId = "test") => ({
providerId: modelId.includes("ollama") ? "ollama" : providerId,
model: { ...mockProviderInfo.model, id: modelId },
@@ -6,7 +6,7 @@ import { ClineToolSet } from "../registry/ClineToolSet"
import { PromptRegistry } from "../registry/PromptRegistry"
import { new_task_variants } from "../tools/new_task"
import type { SystemPromptContext } from "../types"
import { mockProviderInfo } from "./integration.test"
import { mockProviderInfo } from "./test-fixtures"
const baseContext: SystemPromptContext = {
cwd: "/test/project",
@@ -0,0 +1,8 @@
// Shared provider info fixture used across the system-prompt test suite.
// Kept in a non-`.test.ts` file (like matcher-test.ts) so it can be exported
// and imported by sibling test files without tripping biome's noExportsInTest rule.
export const mockProviderInfo = {
providerId: "test",
model: { id: "fast", info: { supportsPromptCache: false } },
mode: "act" as const,
}
@@ -60,7 +60,7 @@ describe("fetchRemoteConfig", () => {
sandbox.restore();
});
it("clears remote config and skips discovery when Cline Pass is selected", async () => {
it("clears remote config and skips discovery when ClinePass is selected", async () => {
const controller = createController({
stateManager: {
getApiConfiguration: sandbox.stub().returns({
+25 -10
View File
@@ -87,7 +87,6 @@ import {
} from "@shared/Languages";
import { USER_CONTENT_TAGS } from "@shared/messages/constants";
import { convertClineMessageToProto } from "@shared/proto-conversions/cline-message";
import { FeatureFlag } from "@shared/services/feature-flags/feature-flags";
import { type ClineDefaultTool, READ_ONLY_TOOLS } from "@shared/tools";
import type { ClineAskResponse } from "@shared/WebviewMessage";
import {
@@ -2039,11 +2038,7 @@ export class Task {
? apiConfig.planModeApiProvider
: apiConfig.actModeApiProvider
) as string;
const providerId =
configuredProviderId === "cline-pass" &&
!featureFlagsService.getBooleanFlagEnabled(FeatureFlag.CLINE_PASS)
? "cline"
: configuredProviderId;
const providerId = configuredProviderId;
const customPrompt = this.stateManager.getGlobalSettingsKey("customPrompt");
return { model, providerId, customPrompt, mode };
}
@@ -2469,6 +2464,14 @@ export class Task {
const isEntitlementError = clineError.isErrorType(
ClineErrorType.Entitlement,
);
const isOrgClinePassRestrictionError = clineError.isErrorType(
ClineErrorType.OrgClinePassRestriction,
);
// ClinePass period limits reset in hours/days — auto-retrying is
// pointless and only delays the actionable error UI.
const isClinePassLimitError = clineError.isErrorType(
ClineErrorType.ClinePassLimit,
);
// Check if this is a Cline provider insufficient credits error - don't auto-retry these
const isClineProviderInsufficientCredits = (() => {
@@ -2495,6 +2498,8 @@ export class Task {
!isSpendLimitError &&
!quotaExceeded &&
!isEntitlementError &&
!isOrgClinePassRestrictionError &&
!isClinePassLimitError &&
this.taskState.autoRetryAttempts < 3;
if (shouldRetry) {
// Auto-retry enabled with max 3 attempts: automatically approve the retry
@@ -2557,7 +2562,9 @@ export class Task {
!isAuthError &&
!isSpendLimitError &&
!quotaExceeded &&
!isEntitlementError;
!isEntitlementError &&
!isOrgClinePassRestrictionError &&
!isClinePassLimitError;
if (showRetry) {
await this.say(
"error_retry",
@@ -2820,6 +2827,16 @@ export class Task {
this.taskState.consecutiveMistakeCount >=
this.stateManager.getGlobalSettingsKey("maxConsecutiveMistakes")
) {
telemetryService.captureMistakeLimitReached({
ulid: this.ulid,
model: model.id,
provider: providerId,
consecutiveMistakes: this.taskState.consecutiveMistakeCount,
maxConsecutiveMistakes: this.stateManager.getGlobalSettingsKey(
"maxConsecutiveMistakes",
),
yoloMode: this.stateManager.getGlobalSettingsKey("yoloModeToggled"),
});
// In yolo mode, don't wait for user input - fail the task
if (this.stateManager.getGlobalSettingsKey("yoloModeToggled")) {
const errorMessage =
@@ -2842,9 +2859,7 @@ export class Task {
}
const { response, text, images, files } = await this.ask(
"mistake_limit_reached",
this.api.getModel().id.includes("claude")
? `This may indicate a failure in Cline's thought process or inability to use a tool properly, which can be mitigated with some user guidance (e.g. "Try breaking down the task into smaller steps").`
: "Cline uses complex prompts and iterative task execution that may be challenging for less capable models. For best results, it's recommended to use Claude 4.5 Sonnet for its advanced agentic coding capabilities.",
`Cline hit repeated tool call failures. Try guiding it with a new prompt.`,
);
if (response === "messageResponse") {
// Display the user's message in the chat UI
File diff suppressed because it is too large Load Diff
+5
View File
@@ -53,10 +53,15 @@ import { ExtensionRegistryInfo } from "./registry"
import { AuthService } from "./services/auth/AuthService"
import { LogoutReason } from "./services/auth/types"
import { telemetryService } from "./services/telemetry"
import type { RolloutBundleActivation } from "./services/telemetry/rollout-metadata"
import { LG_TASK_URI_PATH, SharedUriHandler, TASK_URI_PATH } from "./services/uri/SharedUriHandler"
import { ShowMessageType } from "./shared/proto/host/window"
import { fileExistsAtPath } from "./utils/fs"
export async function reportRolloutActivation(input: RolloutBundleActivation): Promise<void> {
await telemetryService.captureRolloutBundleActivated(input)
}
// This method is called when the VS Code extension is activated.
// NOTE: This is VS Code specific - services that should be registered
// for all-platform should be registered in common.ts.
+158 -53
View File
@@ -1,5 +1,5 @@
import { serializeError } from "serialize-error"
import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "../../shared/ClineAccount"
import { serializeError } from "serialize-error";
import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "../../shared/ClineAccount";
export enum ClineErrorType {
Auth = "auth",
@@ -9,46 +9,98 @@ export enum ClineErrorType {
SpendLimit = "spendLimit",
QuotaExceeded = "quotaExceeded",
Entitlement = "entitlement",
OrgClinePassRestriction = "orgClinePassRestriction",
ClinePassLimit = "clinePassLimit",
}
interface ErrorDetails {
/**
* The HTTP status code of the error, if applicable.
*/
status?: number
status?: number;
/**
* The request ID associated with the error, if available.
* This can be useful for debugging and support.
*/
request_id?: string
request_id?: string;
/**
* Specific error code provided by the API or service.
*/
code?: string
code?: string;
/**
* The model ID associated with the error, if applicable.
* This is useful for identifying which model the error relates to.
*/
modelId?: string
modelId?: string;
/**
* The provider ID associated with the error, if applicable.
* This is useful for identifying which provider the error relates to.
*/
providerId?: string
providerId?: string;
/**
* The error message associated with the error, if applicable.
*/
message?: string
message?: string;
// Additional details that might be present in the error
// This can include things like current balance, error messages, etc.
details?: any
details?: any;
}
const RATE_LIMIT_PATTERNS = [/status code 429/i, /rate limit/i, /too many requests/i, /quota exceeded/i, /resource exhausted/i]
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";
// The ClinePass period limit message is dynamic ("weekly"/"5-hour" period, "7d"/"12h"
// reset), so it is matched by its fixed prefix/suffix with the ClinePass marker in
// between. Plain indexOf scanning — no regex, so no backtracking on hostile input.
const CLINE_PASS_LIMIT_PREFIX = "you have reached your";
const CLINE_PASS_LIMIT_MARKER = "clinepass limit";
const CLINE_PASS_LIMIT_SUFFIX = "please try again later.";
function findClinePassLimitMessageBounds(
text: string,
): { start: number; end: number } | undefined {
const normalized = text.toLowerCase();
const start = normalized.indexOf(CLINE_PASS_LIMIT_PREFIX);
if (start === -1) {
return undefined;
}
const suffixStart = normalized.indexOf(CLINE_PASS_LIMIT_SUFFIX, start);
if (suffixStart === -1) {
return undefined;
}
const end = suffixStart + CLINE_PASS_LIMIT_SUFFIX.length;
if (!normalized.slice(start, end).includes(CLINE_PASS_LIMIT_MARKER)) {
return undefined;
}
return { start, end };
}
export function isClinePassLimitMessage(text: string): boolean {
return findClinePassLimitMessageBounds(text) !== undefined;
}
export function extractClinePassLimitMessage(
text: string,
): string | undefined {
const bounds = findClinePassLimitMessageBounds(text);
return bounds ? text.slice(bounds.start, bounds.end) : undefined;
}
export class ClineError extends Error {
readonly title = "ClineError"
readonly _error: ErrorDetails
readonly title = "ClineError";
readonly _error: ErrorDetails;
// Error details per providers:
// Cline: error?.error
@@ -59,15 +111,19 @@ export class ClineError extends Error {
public readonly modelId?: string,
public readonly providerId?: string,
) {
const error = serializeError(raw)
const error = serializeError(raw);
const message = error.message || error?.response?.message || String(error) || error?.cause?.means
super(message)
const message =
error.message ||
error?.response?.message ||
String(error) ||
error?.cause?.means;
super(message);
// Extract status from multiple possible locations
const status = error.status || error.statusCode || error.response?.status
this.modelId = modelId || error.modelId
this.providerId = providerId || error.providerId
const status = error.status || error.statusCode || error.response?.status;
this.modelId = modelId || error.modelId;
this.providerId = providerId || error.providerId;
// Construct the error details object to includes relevant information
// And ensure it has a consistent structure
@@ -85,7 +141,7 @@ export class ClineError extends Error {
providerId: this.providerId,
details: error.details || error.error, // Additional details provided by the server
stack: undefined, // Avoid serializing stack trace to keep the error object clean
}
};
}
/**
@@ -101,7 +157,7 @@ export class ClineError extends Error {
modelId: this.modelId,
providerId: this.providerId,
details: this._error.details,
})
});
}
/**
@@ -109,29 +165,33 @@ export class ClineError extends Error {
*/
static parse(errorStr?: string, modelId?: string): ClineError | undefined {
if (!errorStr || typeof errorStr !== "string") {
return undefined
return undefined;
}
return ClineError.transform(errorStr, modelId)
return ClineError.transform(errorStr, modelId);
}
/**
* Transforms any object into a ClineError instance.
* Always returns a ClineError, even if the input is not a valid error object.
*/
static transform(error: any, modelId?: string, providerId?: string): ClineError {
static transform(
error: any,
modelId?: string,
providerId?: string,
): ClineError {
try {
// If already a ClineError, return it directly to prevent infinite recursion
if (error instanceof ClineError) {
return error
return error;
}
return new ClineError(JSON.parse(error), modelId, providerId)
return new ClineError(JSON.parse(error), modelId, providerId);
} catch {
return new ClineError(error, modelId, providerId)
return new ClineError(error, modelId, providerId);
}
}
public isErrorType(type: ClineErrorType): boolean {
return ClineError.getErrorType(this) === type
return ClineError.getErrorType(this) === type;
}
/**
@@ -139,53 +199,98 @@ export class ClineError extends Error {
* This is useful for determining how to handle the error in the UI or logic.
*/
static getErrorType(err: ClineError): ClineErrorType | undefined {
const { code, status, details } = err._error
const message = (err._error?.message || err.message || JSON.stringify(err._error))?.toLowerCase()
const { code, status, details } = err._error;
const message = (
err._error?.message ||
err.message ||
JSON.stringify(err._error)
)?.toLowerCase();
// Check balance error first (most specific)
if (code === "insufficient_credits" && typeof details?.current_balance === "number") {
return ClineErrorType.Balance
if (
code === "insufficient_credits" &&
typeof details?.current_balance === "number"
) {
return ClineErrorType.Balance;
}
// Check spend limit exceeded (org-enforced budget cap, 429 SPEND_LIMIT_EXCEEDED)
// Must be checked before the generic rate-limit check since both use 429
if (code === "SPEND_LIMIT_EXCEEDED" || details?.code === "SPEND_LIMIT_EXCEEDED") {
return ClineErrorType.SpendLimit
if (
code === "SPEND_LIMIT_EXCEEDED" ||
details?.code === "SPEND_LIMIT_EXCEEDED"
) {
return ClineErrorType.SpendLimit;
}
// 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("not subscribed to required model plan")) {
return ClineErrorType.Entitlement
// 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.
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;
}
// ClinePass period limits (weekly/5-hour) are user-actionable (switch to
// usage-based billing) and must not fall through to the generic 403 auth
// handling below or the 429 rate-limit patterns.
const detailMessage =
typeof details?.message === "string" ? details.message : undefined;
if (
isClinePassLimitMessage(detailMessage ?? "") ||
isClinePassLimitMessage(message ?? "")
) {
return ClineErrorType.ClinePassLimit;
}
// Check auth errors
const isAuthStatus = status !== undefined && status > 400 && status < 429
if (code === "ERR_BAD_REQUEST" || err instanceof AuthInvalidTokenError || isAuthStatus) {
return ClineErrorType.Auth
const isAuthStatus = status !== undefined && status > 400 && status < 429;
if (
code === "ERR_BAD_REQUEST" ||
err instanceof AuthInvalidTokenError ||
isAuthStatus
) {
return ClineErrorType.Auth;
}
if (code === "INFERENCE_CAP_ERROR") {
return ClineErrorType.QuotaExceeded
return ClineErrorType.QuotaExceeded;
}
if (message) {
// Check for specific error codes/messages if applicable
const authErrorRegex = [/(?:in)?valid[-_ ]?(?:api )?(?:token|key)/i, /authentication[-_ ]?failed/i, /unauthorized/i]
if (message?.includes(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE) || authErrorRegex.some((regex) => regex.test(message))) {
return ClineErrorType.Auth
const authErrorRegex = [
/(?:in)?valid[-_ ]?(?:api )?(?:token|key)/i,
/authentication[-_ ]?failed/i,
/unauthorized/i,
];
if (
message?.includes(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE) ||
authErrorRegex.some((regex) => regex.test(message))
) {
return ClineErrorType.Auth;
}
// Check rate limit patterns
const lowerMessage = message.toLowerCase()
const lowerMessage = message.toLowerCase();
if (RATE_LIMIT_PATTERNS.some((pattern) => pattern.test(lowerMessage))) {
return ClineErrorType.RateLimit
return ClineErrorType.RateLimit;
}
}
return undefined
return undefined;
}
}
@@ -194,14 +299,14 @@ export class AuthNetworkError extends Error {
message: string,
override readonly cause?: Error,
) {
super(message)
this.name = ClineErrorType.Network
super(message);
this.name = ClineErrorType.Network;
}
}
export class AuthInvalidTokenError extends Error {
constructor(message: string) {
super(message)
this.name = ClineErrorType.Auth
super(message);
this.name = ClineErrorType.Auth;
}
}
@@ -1,42 +1,57 @@
import { describe, it } from "mocha"
import "should"
import { ClineError, ClineErrorType } from "../ClineError"
import { describe, it } from "mocha";
import "should";
import {
ClineError,
ClineErrorType,
extractClinePassLimitMessage,
isClinePassLimitMessage,
} from "../ClineError";
describe("ClineError", () => {
describe("getErrorType", () => {
it("should return QuotaExceeded when code is INFERENCE_CAP_ERROR", () => {
const err = new ClineError({ message: "Inference cap reached", code: "INFERENCE_CAP_ERROR" })
ClineError.getErrorType(err)!.should.equal(ClineErrorType.QuotaExceeded)
})
const err = new ClineError({
message: "Inference cap reached",
code: "INFERENCE_CAP_ERROR",
});
ClineError.getErrorType(err)!.should.equal(ClineErrorType.QuotaExceeded);
});
it("should return Entitlement when code is ENTITLEMENT_ERROR", () => {
const err = new ClineError({
message: "403 Error 403: the user is not subscribed to required model plan",
message:
"403 Error 403: the user is not subscribed to required model plan",
code: "ENTITLEMENT_ERROR",
status: 403,
})
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
})
});
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement);
});
it("should return Entitlement when details.code is ENTITLEMENT_ERROR", () => {
const err = new ClineError({
message: "403 Error 403: the user is not subscribed to required model plan",
message:
"403 Error 403: the user is not subscribed to required model plan",
status: 403,
details: { code: "ENTITLEMENT_ERROR", message: "Error 403: the user is not subscribed to required model plan" },
})
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
})
details: {
code: "ENTITLEMENT_ERROR",
message:
"Error 403: the user is not subscribed to required model plan",
},
});
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement);
});
it("should prefer Entitlement over Auth for 403 ENTITLEMENT_ERROR", () => {
// status 403 would otherwise be classified as Auth; the entitlement code must win.
const err = new ClineError({
message: "403 Error 403: the user is not subscribed to required model plan",
message:
"403 Error 403: the user is not subscribed to required model plan",
code: "ENTITLEMENT_ERROR",
status: 403,
})
ClineError.getErrorType(err)!.should.not.equal(ClineErrorType.Auth)
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
})
});
ClineError.getErrorType(err)!.should.not.equal(ClineErrorType.Auth);
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement);
});
it("should return Entitlement for the real Cline 403 provider error shape (nested error object)", () => {
// ClineError maps `error.error` into `details`, so `details.code` drives classification.
@@ -45,25 +60,108 @@ describe("ClineError", () => {
status: 403,
error: {
code: "ENTITLEMENT_ERROR",
message: "Error 403: the user is not subscribed to required model plan",
message:
"Error 403: the user is not subscribed to required model plan",
},
},
"cline-pass/glm-5.1",
"cline-pass/glm-5.2",
"cline-pass",
)
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
})
);
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement);
});
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.
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.
const err = new ClineError({
message: "403 Error 403: organization accounts cannot use individual model inference subscriptions",
message:
"403 Error 403: organization accounts cannot use individual model inference subscriptions",
code: "ENTITLEMENT_ERROR",
status: 403,
})
const result = ClineError.getErrorType(err)
;(result !== ClineErrorType.Entitlement).should.be.true()
})
})
})
});
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();
});
it("should classify ClinePass period limit messages as ClinePassLimit", () => {
const err = new ClineError(
"You have reached your weekly Clinepass limit. The limit resets in 7d, please try again later.",
);
ClineError.getErrorType(err)!.should.equal(ClineErrorType.ClinePassLimit);
});
it("should classify nested ClinePass period limit messages as ClinePassLimit", () => {
// ClineError maps `error.error` into `details`, matching the real provider error shape.
const err = new ClineError({
message: "403 Error 403",
error: {
message:
"You have reached your monthly ClinePass limit. The limit resets in 12h, please try again later.",
},
});
ClineError.getErrorType(err)!.should.equal(ClineErrorType.ClinePassLimit);
});
it("should prefer ClinePassLimit over Auth for a 403 with the limit message", () => {
// status 403 falls inside the generic auth-status range; the limit message must win.
const err = new ClineError({
message:
"You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.",
status: 403,
});
ClineError.getErrorType(err)!.should.equal(ClineErrorType.ClinePassLimit);
});
});
describe("isClinePassLimitMessage", () => {
it("matches limit messages with variable period and reset", () => {
isClinePassLimitMessage(
"You have reached your weekly Clinepass limit. The limit resets in 7d, please try again later.",
).should.be.true();
isClinePassLimitMessage(
"You have reached your 5-hour ClinePass limit. The limit resets in 5h, please try again later.",
).should.be.true();
});
it("does not match unrelated or partial messages", () => {
isClinePassLimitMessage(
"the user is not subscribed to required model plan",
).should.be.false();
isClinePassLimitMessage(
`You have reached your\t-\tClinepass limit.The limit resets in\t${"\t".repeat(10_000)}`,
).should.be.false();
});
});
describe("extractClinePassLimitMessage", () => {
it("extracts the limit message out of a wrapped error string", () => {
const message =
"You have reached your weekly Clinepass limit. The limit resets in 7d, please try again later.";
extractClinePassLimitMessage(`429 Error: ${message}`)!.should.equal(
message,
);
});
it("returns undefined when there is no limit message", () => {
(
extractClinePassLimitMessage("some other error") === undefined
).should.be.true();
});
});
});
@@ -98,6 +98,49 @@ describe("Telemetry system is abstracted and can easily switch between providers
await localService.dispose()
})
it("should derive host_plugin_version from the host's clineVersion", async () => {
const cases = [
{
hostVersion: {
platform: "IntelliJ IDEA Ultimate",
version: "2026.1.1",
clineType: "Cline for JetBrains",
clineVersion: "1.1.61",
},
expectedHostPluginVersion: "1.1.61" as string | undefined,
},
{
hostVersion: {
platform: "VS Code",
version: "1.103.0",
clineType: "VSCode Extension",
},
expectedHostPluginVersion: undefined,
},
]
for (const { hostVersion, expectedHostPluginVersion } of cases) {
const sandbox = sinon.createSandbox()
let service: TelemetryService | undefined
try {
const provider = new NoOpTelemetryProvider()
const logSpy = sandbox.spy(provider, "log")
sandbox.stub(HostProvider.env, "getHostVersion").resolves(hostVersion)
sandbox.stub(TelemetryProviderFactory, "createProviders").resolves([provider])
service = await TelemetryService.create()
logSpy.resetHistory()
service.captureTaskCreated("task-123", "openai")
assert.ok(logSpy.calledOnce, `service should emit an event (${hostVersion.clineType})`)
assert.strictEqual(logSpy.firstCall.args[1]?.host_plugin_version, expectedHostPluginVersion)
} finally {
sandbox.restore()
await service?.dispose()
}
}
})
it("should include remote workspace metadata on workspace.initialized events", async () => {
const noOpProvider = new NoOpTelemetryProvider()
const logSpy = sinon.spy(noOpProvider, "log")
@@ -11,6 +11,13 @@ import { Mode } from "@/shared/storage/types"
import { version as extensionVersion } from "../../../package.json"
import { setDistinctId } from "../logging/distinctId"
import type { ITelemetryProvider, TelemetryProperties } from "./providers/ITelemetryProvider"
import {
getRolloutErrorProperties,
getRolloutTelemetryMetadata,
ROLLOUT_BUNDLE_ACTIVATED_EVENT,
type RolloutBundleActivation,
type RolloutTelemetryMetadata,
} from "./rollout-metadata"
import { TelemetryProviderFactory } from "./TelemetryProviderFactory"
/**
@@ -80,6 +87,12 @@ export type TelemetryMetadata = {
* all use the same extension or plugin.
*/
cline_type: string
/**
* The version of the host-side Cline distribution package: the JetBrains plugin version
* (e.g. 1.1.61) on JetBrains, the extension version on VSCode (where it matches
* `extension_version`). Absent when the host does not report one (e.g. CLI).
*/
host_plugin_version?: string
/** The name of the host IDE or environment e.g. VSCode, Cursor, IntelliJ Professional Edition, etc. */
platform: string
/** The version of the host environment */
@@ -93,6 +106,8 @@ export type TelemetryMetadata = {
is_remote_workspace: boolean
/** Whether the extension is running in development mode */
is_dev: string | undefined
/** Present only in bundles built by the combined legacy/next rollout workflow. */
extension_variant?: RolloutTelemetryMetadata["extension_variant"]
}
/**
@@ -264,6 +279,8 @@ export class TelemetryService {
GEMINI_API_PERFORMANCE: "task.gemini_api_performance",
// Tracks when API providers return errors
PROVIDER_API_ERROR: "task.provider_api_error",
// Tracks when the consecutive mistake limit is reached
MISTAKE_LIMIT_REACHED: "task.mistake_limit_reached",
// Tracks when users enable the focus chain feature
FOCUS_CHAIN_ENABLED: "task.focus_chain_enabled",
// Tracks when users disable the focus chain feature
@@ -357,6 +374,7 @@ export class TelemetryService {
const hostVersion = await HostProvider.env.getHostVersion({})
const metadata: TelemetryMetadata = {
extension_version: extensionVersion,
...(hostVersion.clineVersion ? { host_plugin_version: hostVersion.clineVersion } : {}),
platform: hostVersion.platform || "unknown",
platform_version: hostVersion.version || "unknown",
cline_type: hostVersion.clineType || "unknown",
@@ -365,6 +383,7 @@ export class TelemetryService {
// `remoteName` is normalized by the host bridge to `undefined` for local workspaces.
is_remote_workspace: !!hostVersion.remoteName,
is_dev: process.env.IS_DEV,
...getRolloutTelemetryMetadata(),
}
return new TelemetryService(providers, metadata)
}
@@ -566,6 +585,22 @@ export class TelemetryService {
})
}
public captureRolloutBundleActivated(input: RolloutBundleActivation): void {
if (!this.telemetryMetadata.extension_variant) {
return
}
this.capture({
event: ROLLOUT_BUNDLE_ACTIVATED_EVENT,
properties: {
attempted_bundle: input.attemptedBundle,
actual_bundle: input.actualBundle,
fallback: input.fallback,
...(input.fallback ? getRolloutErrorProperties(input.error) : {}),
},
})
}
public captureExtensionStorageError(errorMessage: string, eventName: string) {
// Truncate error message to prevent excessive data
this.capture({
@@ -1400,6 +1435,33 @@ export class TelemetryService {
this.recordHistogram(TelemetryService.METRICS.ERRORS.PER_TASK, errorCount, errorAttributes)
}
/**
* Records when the consecutive mistake limit is reached, right before the
* limit decision (user prompt / yolo auto-stop) is resolved
* @param ulid Unique identifier for the task
* @param model Identifier of the model used
* @param provider Identifier of the API provider
* @param consecutiveMistakes Number of consecutive mistakes when the limit was hit
* @param maxConsecutiveMistakes The configured mistake limit
* @param yoloMode Whether yolo mode auto-failed the task instead of asking the user
*/
public captureMistakeLimitReached(args: {
ulid: string
model: string
provider?: string
consecutiveMistakes: number
maxConsecutiveMistakes: number
yoloMode?: boolean
}) {
this.capture({
event: TelemetryService.EVENTS.TASK.MISTAKE_LIMIT_REACHED,
properties: {
...args,
timestamp: new Date().toISOString(),
},
})
}
/**
* Records when focus chain is enabled/disabled by the user
* @param enabled Whether focus chain was enabled (true) or disabled (false)
@@ -1,6 +1,7 @@
import { ApiFormat } from "@shared/proto/cline/models"
import * as assert from "assert"
import type { ITelemetryProvider, TelemetryProperties, TelemetrySettings } from "../providers/ITelemetryProvider"
import { ROLLOUT_BUNDLE_ACTIVATED_EVENT, ROLLOUT_ERROR_MESSAGE_LIMIT } from "../rollout-metadata"
import { TelemetryMetadata, TelemetryService } from "../TelemetryService"
class FakeProvider implements ITelemetryProvider {
@@ -79,6 +80,61 @@ function createTelemetryService(provider: FakeProvider, overrides: Partial<Telem
}
describe("TelemetryService metrics", () => {
it("includes rollout metadata on legacy events and metrics", () => {
const provider = new FakeProvider()
const service = createTelemetryService(provider, {
extension_variant: "legacy",
})
service.captureTaskCreated("task-rollout", "anthropic")
service.captureTokenUsage("task-rollout", 120, 80, "anthropic", "model-a")
const taskEvent = provider.logs.find((entry) => entry.event === "task.created")
assert.strictEqual(taskEvent?.properties?.extension_variant, "legacy")
for (const entry of [...provider.counters, ...provider.histograms]) {
assert.strictEqual(entry.attributes.extension_variant, "legacy")
}
})
it("captures one bounded rollout fallback event for rollout builds", () => {
const provider = new FakeProvider()
const service = createTelemetryService(provider, {
extension_variant: "legacy",
})
service.captureRolloutBundleActivated({
attemptedBundle: "next",
actualBundle: "legacy",
fallback: true,
error: new TypeError("x".repeat(ROLLOUT_ERROR_MESSAGE_LIMIT + 20)),
})
const events = provider.logs.filter((entry) => entry.event === ROLLOUT_BUNDLE_ACTIVATED_EVENT)
assert.strictEqual(events.length, 1)
assert.strictEqual(events[0].properties?.attempted_bundle, "next")
assert.strictEqual(events[0].properties?.actual_bundle, "legacy")
assert.strictEqual(events[0].properties?.fallback, true)
assert.strictEqual(events[0].properties?.error_type, "TypeError")
assert.strictEqual((events[0].properties?.error_message as string).length, ROLLOUT_ERROR_MESSAGE_LIMIT)
assert.strictEqual(events[0].properties?.extension_variant, "legacy")
})
it("does not capture rollout activation events for ordinary builds", () => {
const provider = new FakeProvider()
const service = createTelemetryService(provider)
service.captureRolloutBundleActivated({
attemptedBundle: "legacy",
actualBundle: "legacy",
fallback: false,
})
assert.strictEqual(
provider.logs.some((entry) => entry.event === ROLLOUT_BUNDLE_ACTIVATED_EVENT),
false,
)
})
it("captureTokenUsage emits token counters and histograms", () => {
const provider = new FakeProvider()
const service = createTelemetryService(provider)
@@ -0,0 +1,43 @@
import * as assert from "assert"
import { getRolloutErrorProperties, getRolloutTelemetryMetadata, ROLLOUT_ERROR_MESSAGE_LIMIT } from "../rollout-metadata"
const originalVariant = process.env.CLINE_ROLLOUT_VARIANT
afterEach(() => {
restoreEnv("CLINE_ROLLOUT_VARIANT", originalVariant)
})
describe("rollout telemetry metadata", () => {
it("returns metadata for a rollout build", () => {
process.env.CLINE_ROLLOUT_VARIANT = "legacy"
assert.deepStrictEqual(getRolloutTelemetryMetadata(), {
extension_variant: "legacy",
})
})
it("omits metadata for ordinary or invalid builds", () => {
delete process.env.CLINE_ROLLOUT_VARIANT
assert.deepStrictEqual(getRolloutTelemetryMetadata(), {})
process.env.CLINE_ROLLOUT_VARIANT = "invalid"
assert.deepStrictEqual(getRolloutTelemetryMetadata(), {})
})
it("bounds fallback errors without including stacks", () => {
const error = new TypeError("x".repeat(ROLLOUT_ERROR_MESSAGE_LIMIT + 20))
const properties = getRolloutErrorProperties(error)
assert.strictEqual(properties.error_type, "TypeError")
assert.strictEqual(properties.error_message.length, ROLLOUT_ERROR_MESSAGE_LIMIT)
assert.ok(!properties.error_message.includes("TypeError:"))
})
})
function restoreEnv(key: "CLINE_ROLLOUT_VARIANT", value: string | undefined): void {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
@@ -0,0 +1,43 @@
export type ExtensionVariant = "legacy" | "next"
export interface RolloutTelemetryMetadata {
extension_variant: ExtensionVariant
}
export interface RolloutBundleActivation {
attemptedBundle: ExtensionVariant
actualBundle: ExtensionVariant
fallback: boolean
error?: unknown
}
export const ROLLOUT_BUNDLE_ACTIVATED_EVENT = "extension.rollout.bundle_activated"
export const ROLLOUT_ERROR_MESSAGE_LIMIT = 500
/** Return rollout metadata only for bundles built by the combined rollout workflow. */
export function getRolloutTelemetryMetadata(): Partial<RolloutTelemetryMetadata> {
const variant = process.env.CLINE_ROLLOUT_VARIANT
if (variant !== "legacy" && variant !== "next") {
return {}
}
return { extension_variant: variant }
}
export function getRolloutErrorProperties(error: unknown): {
error_type: string
error_message: string
} {
if (error instanceof Error) {
return {
error_type: error.name || "Error",
error_message: error.message.slice(0, ROLLOUT_ERROR_MESSAGE_LIMIT),
}
}
return {
error_type: error === undefined ? "unknown" : error === null ? "null" : typeof error,
error_message: (error === undefined ? "Unknown activation error" : String(error)).slice(0, ROLLOUT_ERROR_MESSAGE_LIMIT),
}
}
@@ -0,0 +1,82 @@
import { expect } from "chai"
import {
buildModelInfoNameMap,
clinePassModelInfoSaneDefaults,
internationalZAiModels,
mainlandZAiModels,
type ModelInfo,
resolveClinePassModelInfo,
} from "../api"
describe("ClinePass model info", () => {
const createModelInfo = (name: string, contextWindow: number): ModelInfo => ({
name,
contextWindow,
maxTokens: 8_192,
supportsPromptCache: false,
supportsReasoning: false,
thinkingConfig: { maxBudget: 16_384 },
})
it("prefers dynamic model metadata for ClinePass GLM aliases", () => {
const modelInfo = resolveClinePassModelInfo(
"cline-pass/z-ai/glm-5.2",
buildModelInfoNameMap({
"z-ai/glm-5.2": createModelInfo("OpenRouter GLM 5.2", 1_000_000),
}),
)
expect(modelInfo.name).to.equal("OpenRouter GLM 5.2")
expect(modelInfo.contextWindow).to.equal(1_000_000)
expect(modelInfo.thinkingConfig).to.deep.equal({ maxBudget: 16_384 })
})
it("falls back to static ClinePass metadata when dynamic metadata is unavailable", () => {
const modelInfo = resolveClinePassModelInfo("cline-pass/glm-5.2")
expect(modelInfo.contextWindow).to.equal(202_752)
expect(modelInfo.supportsReasoning).to.equal(true)
expect(modelInfo.thinkingConfig).to.equal(undefined)
})
it("preserves dynamic ClinePass model info when no static alias exists", () => {
const modelInfo = resolveClinePassModelInfo(
"cline-pass/new-model",
buildModelInfoNameMap({
"zai/new-model": createModelInfo("New model", 1_000_000),
}),
)
expect(modelInfo.name).to.equal("New model")
expect(modelInfo.supportsReasoning).to.equal(false)
expect(modelInfo.thinkingConfig).to.deep.equal({ maxBudget: 16_384 })
})
it("returns stored info for a free (non cline-pass prefixed) model id", () => {
const freeModelInfo = createModelInfo("Trinity Large Preview", 512_000)
const modelInfo = resolveClinePassModelInfo(
"arcee-ai/trinity-large-preview:free",
buildModelInfoNameMap({ "arcee-ai/trinity-large-preview:free": freeModelInfo }),
)
expect(modelInfo).to.deep.equal(freeModelInfo)
})
it("falls back to sane defaults for a free model id without dynamic metadata", () => {
const modelInfo = resolveClinePassModelInfo("kwaipilot/kat-coder-pro")
expect(modelInfo).to.deep.equal(clinePassModelInfoSaneDefaults)
})
})
describe("Z AI model info", () => {
it("includes GLM 5.2 for both direct Z AI entrypoints", () => {
for (const models of [internationalZAiModels, mainlandZAiModels]) {
expect(models["glm-5.2"].contextWindow).to.equal(1_000_000)
expect(models["glm-5.2"].maxTokens).to.equal(128_000)
expect(models["glm-5.2"].inputPrice).to.equal(1.4)
expect(models["glm-5.2"].outputPrice).to.equal(4.4)
expect(models["glm-5.2"].cacheReadsPrice).to.equal(0.26)
}
})
})
+367 -46
View File
@@ -129,6 +129,7 @@ export const CLAUDE_SONNET_1M_TIERS = [
cacheReadsPrice: 0.6,
},
]
// Claude 4.6+ opus models include the full 1M context window at standard pricing (no long-context premium)
export const CLAUDE_OPUS_1M_TIERS = [
{
contextWindow: 200000,
@@ -139,10 +140,10 @@ export const CLAUDE_OPUS_1M_TIERS = [
},
{
contextWindow: Number.MAX_SAFE_INTEGER,
inputPrice: 10,
outputPrice: 37.5,
cacheWritesPrice: 12.5,
cacheReadsPrice: 1.0,
inputPrice: 5.0,
outputPrice: 25,
cacheWritesPrice: 6.25,
cacheReadsPrice: 0.5,
},
]
export const CLAUDE_FABLE_1M_TIERS = [
@@ -179,10 +180,33 @@ export const hicapModelInfoSaneDefaults: HicapCompatibleModelInfo = {
// Anthropic
// https://docs.anthropic.com/en/docs/about-claude/models // prices updated 2025-01-02
export type AnthropicModelId = keyof typeof anthropicModels
export const anthropicDefaultModelId: AnthropicModelId = "claude-sonnet-4-5-20250929"
export const anthropicDefaultModelId: AnthropicModelId = "claude-sonnet-5"
export const ANTHROPIC_MIN_THINKING_BUDGET = 1_024
export const ANTHROPIC_MAX_THINKING_BUDGET = 6_000
export const anthropicModels = {
"claude-sonnet-5": {
maxTokens: 128_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
supportsReasoning: true,
inputPrice: 2.0,
outputPrice: 10.0,
cacheWritesPrice: 2.5,
cacheReadsPrice: 0.2,
},
"claude-sonnet-5:1m": {
maxTokens: 128_000,
contextWindow: 1_000_000,
supportsImages: true,
supportsPromptCache: true,
supportsReasoning: true,
inputPrice: 2.0,
outputPrice: 10.0,
cacheWritesPrice: 2.5,
cacheReadsPrice: 0.2,
tiers: CLAUDE_SONNET_1M_TIERS,
},
"claude-sonnet-4-6": {
maxTokens: 64_000,
contextWindow: 200_000,
@@ -312,6 +336,29 @@ export const anthropicModels = {
description:
"Anthropic fast mode preview for Claude Opus 4.6 with the 1M context beta enabled. Same model and capabilities with higher output token speed at premium pricing across the full 1M context window. Requires both fast mode and 1M context access on your Anthropic account.",
},
"claude-opus-5": {
maxTokens: 128_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
supportsReasoning: true,
inputPrice: 5.0,
outputPrice: 25.0,
cacheWritesPrice: 6.25,
cacheReadsPrice: 0.5,
},
"claude-opus-5:1m": {
maxTokens: 128_000,
contextWindow: 1_000_000,
supportsImages: true,
supportsPromptCache: true,
supportsReasoning: true,
inputPrice: 5.0,
outputPrice: 25.0,
cacheWritesPrice: 6.25,
cacheReadsPrice: 0.5,
tiers: CLAUDE_OPUS_1M_TIERS,
},
"claude-opus-4-8": {
maxTokens: 128_000,
contextWindow: 200_000,
@@ -471,26 +518,26 @@ export const anthropicModels = {
// Claude Code
export type ClaudeCodeModelId = keyof typeof claudeCodeModels
export const claudeCodeDefaultModelId: ClaudeCodeModelId = "claude-sonnet-4-5-20250929"
export const claudeCodeDefaultModelId: ClaudeCodeModelId = "claude-sonnet-5"
export const claudeCodeModels = {
sonnet: {
...anthropicModels["claude-sonnet-4-5-20250929"],
...anthropicModels["claude-sonnet-5"],
supportsImages: false,
supportsPromptCache: false,
},
"sonnet[1m]": {
...anthropicModels["claude-sonnet-4-5-20250929:1m"],
...anthropicModels["claude-sonnet-5:1m"],
supportsImages: false,
supportsPromptCache: false,
},
opus: {
...anthropicModels["claude-opus-4-8"],
...anthropicModels["claude-opus-5"],
contextWindow: 200_000,
supportsImages: false,
supportsPromptCache: false,
},
"opus[1m]": {
...anthropicModels["claude-opus-4-8:1m"],
...anthropicModels["claude-opus-5:1m"],
supportsImages: false,
supportsPromptCache: false,
},
@@ -499,6 +546,16 @@ export const claudeCodeModels = {
supportsImages: false,
supportsPromptCache: false,
},
"claude-sonnet-5": {
...anthropicModels["claude-sonnet-5"],
supportsImages: false,
supportsPromptCache: false,
},
"claude-sonnet-5[1m]": {
...anthropicModels["claude-sonnet-5:1m"],
supportsImages: false,
supportsPromptCache: false,
},
"claude-sonnet-4-6": {
...anthropicModels["claude-sonnet-4-6"],
supportsImages: false,
@@ -534,6 +591,17 @@ export const claudeCodeModels = {
supportsImages: false,
supportsPromptCache: false,
},
"claude-opus-5": {
...anthropicModels["claude-opus-5"],
contextWindow: 200_000,
supportsImages: false,
supportsPromptCache: false,
},
"claude-opus-5[1m]": {
...anthropicModels["claude-opus-5:1m"],
supportsImages: false,
supportsPromptCache: false,
},
"claude-opus-4-8": {
...anthropicModels["claude-opus-4-8"],
contextWindow: 200_000,
@@ -597,8 +665,33 @@ export const claudeCodeModels = {
// AWS Bedrock
// https://docs.aws.amazon.com/bedrock/latest/userguide/conversation-inference.html
export type BedrockModelId = keyof typeof bedrockModels
export const bedrockDefaultModelId: BedrockModelId = "anthropic.claude-sonnet-4-5-20250929-v1:0"
export const bedrockDefaultModelId: BedrockModelId = "anthropic.claude-sonnet-5"
export const bedrockModels = {
"anthropic.claude-sonnet-5": {
maxTokens: 128_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
supportsReasoning: true,
supportsGlobalEndpoint: true,
inputPrice: 2.0,
outputPrice: 10.0,
cacheWritesPrice: 2.5,
cacheReadsPrice: 0.2,
},
"anthropic.claude-sonnet-5:1m": {
maxTokens: 128_000,
contextWindow: 1_000_000,
supportsImages: true,
supportsPromptCache: true,
supportsReasoning: true,
supportsGlobalEndpoint: true,
inputPrice: 2.0,
outputPrice: 10.0,
cacheWritesPrice: 2.5,
cacheReadsPrice: 0.2,
tiers: CLAUDE_SONNET_1M_TIERS,
},
"anthropic.claude-sonnet-4-6": {
maxTokens: 64_000,
contextWindow: 200_000,
@@ -711,6 +804,31 @@ export const bedrockModels = {
cacheReadsPrice: 0.5,
tiers: CLAUDE_OPUS_1M_TIERS,
},
"anthropic.claude-opus-5": {
maxTokens: 128_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
supportsReasoning: true,
supportsGlobalEndpoint: true,
inputPrice: 5.0,
outputPrice: 25.0,
cacheWritesPrice: 6.25,
cacheReadsPrice: 0.5,
},
"anthropic.claude-opus-5:1m": {
maxTokens: 128_000,
contextWindow: 1_000_000,
supportsImages: true,
supportsPromptCache: true,
supportsReasoning: true,
supportsGlobalEndpoint: true,
inputPrice: 5.0,
outputPrice: 25.0,
cacheWritesPrice: 6.25,
cacheReadsPrice: 0.5,
tiers: CLAUDE_OPUS_1M_TIERS,
},
"anthropic.claude-opus-4-8": {
maxTokens: 128_000,
contextWindow: 200_000,
@@ -991,25 +1109,27 @@ export const bedrockModels = {
// OpenRouter
// https://openrouter.ai/models?order=newest&supported_parameters=tools
export const openRouterDefaultModelId = "anthropic/claude-sonnet-4.5" // will always exist in openRouterModels
export const openRouterDefaultModelId = "anthropic/claude-sonnet-5" // will always exist in openRouterModels
export const openRouterClaudeSonnet41mModelId = `anthropic/claude-sonnet-4${CLAUDE_SONNET_1M_SUFFIX}`
export const openRouterClaudeSonnet451mModelId = `anthropic/claude-sonnet-4.5${CLAUDE_SONNET_1M_SUFFIX}`
export const openRouterClaudeSonnet461mModelId = `anthropic/claude-sonnet-4.6${CLAUDE_SONNET_1M_SUFFIX}`
export const openRouterClaudeSonnet51mModelId = `anthropic/claude-sonnet-5${CLAUDE_SONNET_1M_SUFFIX}`
export const openRouterClaudeOpus461mModelId = `anthropic/claude-opus-4.6${CLAUDE_SONNET_1M_SUFFIX}`
export const openRouterClaudeOpus471mModelId = `anthropic/claude-opus-4.7${CLAUDE_SONNET_1M_SUFFIX}`
export const openRouterClaudeOpus481mModelId = `anthropic/claude-opus-4.8${CLAUDE_SONNET_1M_SUFFIX}`
export const openRouterClaudeOpus51mModelId = `anthropic/claude-opus-5${CLAUDE_SONNET_1M_SUFFIX}`
export const openRouterClaudeFable51mModelId = `anthropic/claude-fable-5${CLAUDE_SONNET_1M_SUFFIX}`
export const openRouterDefaultModelInfo: ModelInfo = {
maxTokens: 64_000,
maxTokens: 128_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
inputPrice: 2.0,
outputPrice: 10.0,
cacheWritesPrice: 2.5,
cacheReadsPrice: 0.2,
description:
"Claude Sonnet 4.5 delivers superior intelligence across coding, agentic search, and AI agent capabilities. It's a powerful choice for agentic coding, and can complete tasks across the entire software development lifecycle, from initial planning to bug fixes, maintenance to large refactors. It offers strong performance in both planning and solving for complex coding tasks, making it an ideal choice to power end-to-end software development processes.\n\nRead more in the [blog post here](https://www.anthropic.com/claude/sonnet)",
"Claude Sonnet 5 is Anthropic's latest Sonnet model for coding, agents, and professional work. It supports adaptive thinking, prompt caching, image inputs, and long-context workflows.",
}
// Cline custom model - Devstral
@@ -1025,7 +1145,7 @@ export const clineDevstralModelInfo: ModelInfo = {
}
export type ClinePassModelId = keyof typeof clinePassModels
export const clinePassDefaultModelId = "cline-pass/glm-5.1"
export const clinePassDefaultModelId = "cline-pass/glm-5.2"
export const clinePassModelInfoSaneDefaults: ModelInfo = {
maxTokens: 8_192,
contextWindow: 128_000,
@@ -1039,8 +1159,8 @@ export const clinePassModelInfoSaneDefaults: ModelInfo = {
description: "",
}
export const clinePassModels = {
"cline-pass/glm-5.1": {
name: "cline-pass/glm-5.1",
"cline-pass/glm-5.2": {
name: "cline-pass/glm-5.2",
maxTokens: 131_072,
contextWindow: 202_752,
supportsImages: false,
@@ -1069,9 +1189,12 @@ export function buildModelInfoNameMap(models: Record<string, ModelInfo>): Record
}
export function resolveClinePassModelInfo(modelId: string, modelInfoByName?: Record<string, ModelInfo>): ModelInfo {
const modelSlug = getModelSlug(modelId)
const clinePassSlugModelId = `cline-pass/${modelSlug}`
return (
modelInfoByName?.[modelSlug] ??
clinePassModels[modelId as keyof typeof clinePassModels] ??
modelInfoByName?.[getModelSlug(modelId)] ??
clinePassModels[clinePassSlugModelId as keyof typeof clinePassModels] ??
clinePassModelInfoSaneDefaults
)
}
@@ -1230,19 +1353,46 @@ export const vertexModels = {
supportsThinkingLevel: true,
},
},
"claude-sonnet-4-6": {
maxTokens: 64_000,
contextWindow: 200_000,
"claude-sonnet-5": {
maxTokens: 128_000,
contextWindow: 1_000_000,
supportsImages: true,
supportsPromptCache: true,
supportsGlobalEndpoint: true,
inputPrice: 2.0,
outputPrice: 10.0,
cacheWritesPrice: 2.5,
cacheReadsPrice: 0.2,
supportsReasoning: true,
tiers: CLAUDE_SONNET_1M_TIERS,
},
"claude-sonnet-5:1m": {
maxTokens: 128_000,
contextWindow: 1_000_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 2.0,
outputPrice: 10.0,
cacheWritesPrice: 2.5,
cacheReadsPrice: 0.2,
supportsReasoning: true,
tiers: CLAUDE_SONNET_1M_TIERS,
},
"claude-sonnet-4-6": {
maxTokens: 128_000,
contextWindow: 1_000_000,
supportsImages: true,
supportsPromptCache: true,
supportsGlobalEndpoint: true,
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
supportsReasoning: true,
tiers: CLAUDE_SONNET_1M_TIERS,
},
"claude-sonnet-4-6:1m": {
maxTokens: 64_000,
maxTokens: 128_000,
contextWindow: 1_000_000,
supportsImages: true,
supportsPromptCache: true,
@@ -1264,6 +1414,19 @@ export const vertexModels = {
cacheReadsPrice: 0.3,
supportsReasoning: true,
},
"claude-sonnet-4-5": {
maxTokens: 64_000,
contextWindow: 1_000_000,
supportsImages: true,
supportsPromptCache: true,
supportsGlobalEndpoint: true,
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
supportsReasoning: true,
tiers: CLAUDE_SONNET_1M_TIERS,
},
"claude-sonnet-4@20250514": {
maxTokens: 64_000,
contextWindow: 200_000,
@@ -1286,9 +1449,21 @@ export const vertexModels = {
cacheReadsPrice: 0.1,
supportsReasoning: true,
},
"claude-haiku-4-5": {
maxTokens: 64_000,
contextWindow: 200_000,
supportsImages: false,
supportsPromptCache: true,
supportsGlobalEndpoint: true,
inputPrice: 1.0,
outputPrice: 5.0,
cacheWritesPrice: 1.25,
cacheReadsPrice: 0.1,
supportsReasoning: true,
},
"claude-opus-4-6": {
maxTokens: 128_000,
contextWindow: 200_000,
contextWindow: 1_000_000,
supportsImages: true,
supportsPromptCache: true,
supportsGlobalEndpoint: true,
@@ -1297,6 +1472,7 @@ export const vertexModels = {
cacheWritesPrice: 6.25,
cacheReadsPrice: 0.5,
supportsReasoning: true,
tiers: CLAUDE_OPUS_1M_TIERS,
},
"claude-opus-4-6:1m": {
maxTokens: 128_000,
@@ -1311,9 +1487,9 @@ export const vertexModels = {
supportsReasoning: true,
tiers: CLAUDE_OPUS_1M_TIERS,
},
"claude-opus-4-8": {
"claude-opus-5": {
maxTokens: 128_000,
contextWindow: 200_000,
contextWindow: 1_000_000,
supportsImages: true,
supportsPromptCache: true,
supportsGlobalEndpoint: true,
@@ -1322,6 +1498,33 @@ export const vertexModels = {
cacheWritesPrice: 6.25,
cacheReadsPrice: 0.5,
supportsReasoning: true,
tiers: CLAUDE_OPUS_1M_TIERS,
},
"claude-opus-5:1m": {
maxTokens: 128_000,
contextWindow: 1_000_000,
supportsImages: true,
supportsPromptCache: true,
supportsGlobalEndpoint: true,
inputPrice: 5.0,
outputPrice: 25.0,
cacheWritesPrice: 6.25,
cacheReadsPrice: 0.5,
supportsReasoning: true,
tiers: CLAUDE_OPUS_1M_TIERS,
},
"claude-opus-4-8": {
maxTokens: 128_000,
contextWindow: 1_000_000,
supportsImages: true,
supportsPromptCache: true,
supportsGlobalEndpoint: true,
inputPrice: 5.0,
outputPrice: 25.0,
cacheWritesPrice: 6.25,
cacheReadsPrice: 0.5,
supportsReasoning: true,
tiers: CLAUDE_OPUS_1M_TIERS,
},
"claude-opus-4-8:1m": {
maxTokens: 128_000,
@@ -1338,7 +1541,7 @@ export const vertexModels = {
},
"claude-fable-5": {
maxTokens: 128_000,
contextWindow: 200_000,
contextWindow: 1_000_000,
supportsImages: true,
supportsPromptCache: true,
supportsGlobalEndpoint: true,
@@ -1347,6 +1550,7 @@ export const vertexModels = {
cacheWritesPrice: 12.5,
cacheReadsPrice: 1,
supportsReasoning: true,
tiers: CLAUDE_FABLE_1M_TIERS,
},
"claude-fable-5:1m": {
maxTokens: 128_000,
@@ -1363,7 +1567,7 @@ export const vertexModels = {
},
"claude-opus-4-7": {
maxTokens: 128_000,
contextWindow: 200_000,
contextWindow: 1_000_000,
supportsImages: true,
supportsPromptCache: true,
supportsGlobalEndpoint: true,
@@ -1372,6 +1576,7 @@ export const vertexModels = {
cacheWritesPrice: 6.25,
cacheReadsPrice: 0.5,
supportsReasoning: true,
tiers: CLAUDE_OPUS_1M_TIERS,
},
"claude-opus-4-7:1m": {
maxTokens: 128_000,
@@ -1397,6 +1602,18 @@ export const vertexModels = {
cacheReadsPrice: 0.5,
supportsReasoning: true,
},
"claude-opus-4-5": {
maxTokens: 64_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
supportsGlobalEndpoint: true,
inputPrice: 5.0,
outputPrice: 25.0,
cacheWritesPrice: 6.25,
cacheReadsPrice: 0.5,
supportsReasoning: true,
},
"claude-opus-4-1@20250805": {
maxTokens: 32_000,
contextWindow: 200_000,
@@ -1408,6 +1625,18 @@ export const vertexModels = {
cacheReadsPrice: 1.5,
supportsReasoning: true,
},
"claude-opus-4-1": {
maxTokens: 32_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
supportsGlobalEndpoint: true,
inputPrice: 15.0,
outputPrice: 75.0,
cacheWritesPrice: 18.75,
cacheReadsPrice: 1.5,
supportsReasoning: true,
},
"claude-opus-4@20250514": {
maxTokens: 32_000,
contextWindow: 200_000,
@@ -1705,6 +1934,27 @@ export const vertexGlobalModels: Record<string, ModelInfo> = Object.fromEntries(
Object.entries(vertexModels).filter(([_k, v]) => Object.hasOwn(v, "supportsGlobalEndpoint")),
) as Record<string, ModelInfo>
// Defaults for custom (free-form) Vertex model entries. Context window, max output
// tokens, image support, and reasoning support are user-editable in settings.
export const vertexCustomModelInfoSaneDefaults: ModelInfo = {
maxTokens: 64_000,
contextWindow: 200_000,
supportsImages: true,
supportsReasoning: true,
supportsPromptCache: true,
supportsGlobalEndpoint: true,
}
export function getVertexCustomModelInfo(customModelInfo?: ModelInfo): ModelInfo {
return {
...vertexCustomModelInfoSaneDefaults,
...customModelInfo,
// Fixed for all custom Vertex models regardless of user edits.
supportsPromptCache: true,
supportsGlobalEndpoint: true,
}
}
export const openAiModelInfoSaneDefaults: OpenAiCompatibleModelInfo = {
maxTokens: -1,
contextWindow: 128_000,
@@ -2369,6 +2619,42 @@ export const openAiNativeModels = {
export type OpenAiCodexModelId = keyof typeof openAiCodexModels
export const openAiCodexDefaultModelId: OpenAiCodexModelId = "gpt-5.3-codex"
export const openAiCodexModels = {
"gpt-5.6-sol": {
maxTokens: 128_000,
contextWindow: 372_000,
supportsImages: true,
supportsPromptCache: true,
supportsReasoning: true,
apiFormat: ApiFormat.OPENAI_RESPONSES,
// Subscription-based: no per-token costs
inputPrice: 0,
outputPrice: 0,
description: "GPT-5.6 Sol: OpenAI's latest frontier agentic coding model via ChatGPT subscription",
},
"gpt-5.6-terra": {
maxTokens: 128_000,
contextWindow: 372_000,
supportsImages: true,
supportsPromptCache: true,
supportsReasoning: true,
apiFormat: ApiFormat.OPENAI_RESPONSES,
// Subscription-based: no per-token costs
inputPrice: 0,
outputPrice: 0,
description: "GPT-5.6 Terra: OpenAI's balanced agentic coding model via ChatGPT subscription",
},
"gpt-5.6-luna": {
maxTokens: 128_000,
contextWindow: 372_000,
supportsImages: true,
supportsPromptCache: true,
supportsReasoning: true,
apiFormat: ApiFormat.OPENAI_RESPONSES,
// Subscription-based: no per-token costs
inputPrice: 0,
outputPrice: 0,
description: "GPT-5.6 Luna: OpenAI's fast agentic coding model via ChatGPT subscription",
},
"gpt-5.5": {
maxTokens: 128_000,
contextWindow: 1_000_000,
@@ -4360,18 +4646,18 @@ export const groqModels = {
// Requesty
// https://requesty.ai/models
export const requestyDefaultModelId = "anthropic/claude-3-7-sonnet-latest"
export const requestyDefaultModelId = "anthropic/claude-sonnet-5"
export const requestyDefaultModelInfo: ModelInfo = {
maxTokens: 8192,
maxTokens: 128_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
description: "Anthropic's most intelligent model. Highest level of intelligence and capability.",
inputPrice: 2.0,
outputPrice: 10.0,
cacheWritesPrice: 2.5,
cacheReadsPrice: 0.2,
description: "Anthropic's latest Sonnet model for coding, agents, and professional work.",
}
// SAP AI Core
@@ -4380,6 +4666,13 @@ export const sapAiCoreDefaultModelId: SapAiCoreModelId = "anthropic--claude-3.5-
// Pricing is calculated using Capacity Units, not directly in USD
const sapAiCoreModelDescription = "Pricing is calculated using SAP's Capacity Units rather than direct USD pricing."
export const sapAiCoreModels = {
"anthropic--claude-sonnet-5": {
maxTokens: 128_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
description: sapAiCoreModelDescription,
},
"anthropic--claude-4.5-haiku": {
maxTokens: 64000,
contextWindow: 200_000,
@@ -4695,6 +4988,16 @@ export const sapAiCoreModels = {
// Moonshot AI Studio
// https://platform.moonshot.ai/docs/pricing/chat
export const moonshotModels = {
"kimi-k3": {
maxTokens: 131_072,
contextWindow: 1_048_576,
supportsImages: true,
supportsPromptCache: true,
supportsReasoning: true,
inputPrice: 3.0,
outputPrice: 15.0,
cacheReadsPrice: 0.3,
},
"kimi-k2.6": {
maxTokens: 32_000,
contextWindow: 262_144,
@@ -4983,12 +5286,22 @@ export type BasetenModelId = keyof typeof basetenModels
export const basetenDefaultModelId = "zai-org/GLM-4.6" satisfies BasetenModelId
// Z AI
// https://docs.z.ai/guides/llm/glm-5.2
// https://docs.z.ai/guides/llm/glm-5.1
// https://docs.z.ai/guides/llm/glm-5
// https://docs.z.ai/guides/overview/pricing
export type internationalZAiModelId = keyof typeof internationalZAiModels
export const internationalZAiDefaultModelId: internationalZAiModelId = "glm-5.1"
export const internationalZAiModels = {
"glm-5.2": {
maxTokens: 128_000,
contextWindow: 1_000_000,
supportsImages: false,
supportsPromptCache: true,
cacheReadsPrice: 0.26,
inputPrice: 1.4,
outputPrice: 4.4,
},
"glm-5.1": {
maxTokens: 128_000,
contextWindow: 200_000,
@@ -5054,6 +5367,15 @@ export const internationalZAiModels = {
export type mainlandZAiModelId = keyof typeof mainlandZAiModels
export const mainlandZAiDefaultModelId: mainlandZAiModelId = "glm-5.1"
export const mainlandZAiModels = {
"glm-5.2": {
maxTokens: 128_000,
contextWindow: 1_000_000,
supportsImages: false,
supportsPromptCache: true,
cacheReadsPrice: 0.26,
inputPrice: 1.4,
outputPrice: 4.4,
},
"glm-5.1": {
maxTokens: 128_000,
contextWindow: 200_000,
@@ -5193,8 +5515,7 @@ export const fireworksModels = {
outputPrice: 8,
cacheWritesPrice: 0,
cacheReadsPrice: 0.3,
description:
"Kimi K2.6 Turbo router for high-performance agentic workloads with vision and text reasoning.",
description: "Kimi K2.6 Turbo router for high-performance agentic workloads with vision and text reasoning.",
},
"accounts/fireworks/routers/kimi-k2p6-fast": {
maxTokens: 262000,
@@ -5205,8 +5526,7 @@ export const fireworksModels = {
outputPrice: 8,
cacheWritesPrice: 0,
cacheReadsPrice: 0.3,
description:
"Kimi K2.6 Fast router for high-performance agentic workloads with vision and text reasoning.",
description: "Kimi K2.6 Fast router for high-performance agentic workloads with vision and text reasoning.",
},
"accounts/fireworks/routers/kimi-k2p7-code-fast": {
maxTokens: 262000,
@@ -5217,8 +5537,7 @@ export const fireworksModels = {
outputPrice: 8,
cacheWritesPrice: 0,
cacheReadsPrice: 0.38,
description:
"Kimi K2.7 Code Fast router for high-performance coding workloads with vision and text reasoning.",
description: "Kimi K2.7 Code Fast router for high-performance coding workloads with vision and text reasoning.",
},
"accounts/fireworks/models/deepseek-v4-flash": {
maxTokens: 384000,
@@ -5253,7 +5572,8 @@ export const fireworksModels = {
outputPrice: 4.4,
cacheWritesPrice: 0,
cacheReadsPrice: 0.26,
description: "GLM 5.2 is a next-generation general-purpose model optimized for coding, reasoning, and agentic workflows with a 1M context window.",
description:
"GLM 5.2 is a next-generation general-purpose model optimized for coding, reasoning, and agentic workflows with a 1M context window.",
},
"accounts/fireworks/models/glm-5p1": {
maxTokens: 131072,
@@ -5297,7 +5617,8 @@ export const fireworksModels = {
outputPrice: 1.2,
cacheWritesPrice: 0,
cacheReadsPrice: 0.06,
description: "MiniMax M2.7 is tuned for strong real-world performance across coding, agent-driven, and workflow-heavy tasks.",
description:
"MiniMax M2.7 is tuned for strong real-world performance across coding, agent-driven, and workflow-heavy tasks.",
},
"accounts/fireworks/models/qwen3p7-plus": {
maxTokens: 262144,
+4 -4
View File
@@ -55,8 +55,8 @@ export const CLINE_ONBOARDING_MODELS: OnboardingModel[] = [
},
{
group: "frontier",
id: "anthropic/claude-sonnet-4.5",
name: "Anthropic: Claude Sonnet 4.5",
id: "anthropic/claude-sonnet-5",
name: "Anthropic: Claude Sonnet 5",
badge: "Best",
score: 97,
latency: 3,
@@ -64,8 +64,8 @@ export const CLINE_ONBOARDING_MODELS: OnboardingModel[] = [
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 3.0,
outputPrice: 15.0,
inputPrice: 2.0,
outputPrice: 10.0,
tiers: [],
},
},
@@ -22,8 +22,8 @@ export const CLINE_RECOMMENDED_MODELS_FALLBACK: ClineRecommendedModelsData = {
tags: ["NEW"],
},
{
id: "anthropic/claude-sonnet-4.6",
name: "Anthropic Claude Sonnet 4.6",
id: "anthropic/claude-sonnet-5",
name: "Anthropic Claude Sonnet 5",
description: "Latest Sonnet release with strong coding and agent performance",
tags: ["NEW"],
},
@@ -55,6 +55,7 @@ function convertModelInfoToProtoOpenRouter(info: ModelInfo | undefined): OpenRou
contextWindow: info.contextWindow,
supportsImages: info.supportsImages,
supportsPromptCache: info.supportsPromptCache ?? false,
supportsReasoning: info.supportsReasoning,
inputPrice: info.inputPrice,
outputPrice: info.outputPrice,
cacheWritesPrice: info.cacheWritesPrice,
@@ -77,6 +78,7 @@ function convertProtoToModelInfo(info: OpenRouterModelInfo | undefined): ModelIn
contextWindow: info.contextWindow,
supportsImages: info.supportsImages,
supportsPromptCache: info.supportsPromptCache,
supportsReasoning: info.supportsReasoning,
inputPrice: info.inputPrice,
outputPrice: info.outputPrice,
cacheWritesPrice: info.cacheWritesPrice,
@@ -528,6 +530,8 @@ export function convertApiConfigurationToProto(config: ApiConfiguration): ProtoA
planModeVsCodeLmModelSelector: config.planModeVsCodeLmModelSelector,
planModeAwsBedrockCustomSelected: config.planModeAwsBedrockCustomSelected,
planModeAwsBedrockCustomModelBaseId: config.planModeAwsBedrockCustomModelBaseId as string | undefined,
planModeVertexCustomModelSelected: config.planModeVertexCustomModelSelected,
planModeVertexCustomModelInfo: convertModelInfoToProtoOpenRouter(config.planModeVertexCustomModelInfo),
planModeOpenRouterModelId: config.planModeOpenRouterModelId,
planModeOpenRouterModelInfo: convertModelInfoToProtoOpenRouter(config.planModeOpenRouterModelInfo),
planModeClineModelId: config.planModeClineModelId,
@@ -574,6 +578,8 @@ export function convertApiConfigurationToProto(config: ApiConfiguration): ProtoA
actModeVsCodeLmModelSelector: config.actModeVsCodeLmModelSelector,
actModeAwsBedrockCustomSelected: config.actModeAwsBedrockCustomSelected,
actModeAwsBedrockCustomModelBaseId: config.actModeAwsBedrockCustomModelBaseId as string | undefined,
actModeVertexCustomModelSelected: config.actModeVertexCustomModelSelected,
actModeVertexCustomModelInfo: convertModelInfoToProtoOpenRouter(config.actModeVertexCustomModelInfo),
actModeOpenRouterModelId: config.actModeOpenRouterModelId,
actModeOpenRouterModelInfo: convertModelInfoToProtoOpenRouter(config.actModeOpenRouterModelInfo),
actModeClineModelId: config.actModeClineModelId,
@@ -715,6 +721,8 @@ export function convertProtoToApiConfiguration(protoConfig: ProtoApiConfiguratio
planModeVsCodeLmModelSelector: protoConfig.planModeVsCodeLmModelSelector,
planModeAwsBedrockCustomSelected: protoConfig.planModeAwsBedrockCustomSelected,
planModeAwsBedrockCustomModelBaseId: protoConfig.planModeAwsBedrockCustomModelBaseId as BedrockModelId | undefined,
planModeVertexCustomModelSelected: protoConfig.planModeVertexCustomModelSelected,
planModeVertexCustomModelInfo: convertProtoToModelInfo(protoConfig.planModeVertexCustomModelInfo),
planModeOpenRouterModelId: protoConfig.planModeOpenRouterModelId,
planModeOpenRouterModelInfo: convertProtoToModelInfo(protoConfig.planModeOpenRouterModelInfo),
planModeClineModelId: protoConfig.planModeClineModelId,
@@ -762,6 +770,8 @@ export function convertProtoToApiConfiguration(protoConfig: ProtoApiConfiguratio
actModeVsCodeLmModelSelector: protoConfig.actModeVsCodeLmModelSelector,
actModeAwsBedrockCustomSelected: protoConfig.actModeAwsBedrockCustomSelected,
actModeAwsBedrockCustomModelBaseId: protoConfig.actModeAwsBedrockCustomModelBaseId as BedrockModelId | undefined,
actModeVertexCustomModelSelected: protoConfig.actModeVertexCustomModelSelected,
actModeVertexCustomModelInfo: convertProtoToModelInfo(protoConfig.actModeVertexCustomModelInfo),
actModeOpenRouterModelId: protoConfig.actModeOpenRouterModelId,
actModeOpenRouterModelInfo: convertProtoToModelInfo(protoConfig.actModeOpenRouterModelInfo),
actModeClineModelId: protoConfig.actModeClineModelId,
@@ -6,7 +6,7 @@
},
{
"value": "cline-pass",
"label": "Cline Pass"
"label": "ClinePass"
},
{
"value": "openai-codex",
@@ -15,7 +15,7 @@ export enum FeatureFlag {
// Rollout flag for Cline provider model sourcing:
// off => OpenRouter model list, on => Cline endpoint model list.
EXTENSION_CLINE_MODELS_ENDPOINT = "extension_cline_models_endpoint",
// Enables Cline Pass provider/model list exposure.
// Enables ClinePass provider/model list exposure.
CLINE_PASS = "ext-cline-pass",
// Use the websocket mode for OpenAI native Responses API format
OPENAI_RESPONSES_WEBSOCKET_MODE = "openai-responses-websocket-mode",
@@ -23,7 +23,7 @@ describe("Provider key mapping", () => {
expect(getProviderModelIdKey("cline", "plan")).to.equal("planModeClineModelId")
})
it("uses separate model keys for Cline Pass", () => {
it("uses separate model keys for ClinePass", () => {
expect(getProviderModelIdKey("cline-pass", "act")).to.equal("actModeClinePassModelId")
expect(getProviderModelIdKey("cline-pass", "plan")).to.equal("planModeClinePassModelId")
})
@@ -152,6 +152,8 @@ const API_HANDLER_SETTINGS_FIELDS = {
planModeVsCodeLmModelSelector: { default: undefined as LanguageModelChatSelector | undefined },
planModeAwsBedrockCustomSelected: { default: undefined as boolean | undefined },
planModeAwsBedrockCustomModelBaseId: { default: undefined as string | undefined },
planModeVertexCustomModelSelected: { default: undefined as boolean | undefined },
planModeVertexCustomModelInfo: { default: undefined as ModelInfo | undefined },
planModeOpenRouterModelId: { default: undefined as string | undefined },
planModeOpenRouterModelInfo: { default: undefined as ModelInfo | undefined },
planModeClineModelId: { default: undefined as string | undefined },
@@ -198,6 +200,8 @@ const API_HANDLER_SETTINGS_FIELDS = {
actModeVsCodeLmModelSelector: { default: undefined as LanguageModelChatSelector | undefined },
actModeAwsBedrockCustomSelected: { default: undefined as boolean | undefined },
actModeAwsBedrockCustomModelBaseId: { default: undefined as string | undefined },
actModeVertexCustomModelSelected: { default: undefined as boolean | undefined },
actModeVertexCustomModelInfo: { default: undefined as ModelInfo | undefined },
actModeOpenRouterModelId: { default: undefined as string | undefined },
actModeOpenRouterModelInfo: { default: undefined as ModelInfo | undefined },
actModeClineModelId: { default: undefined as string | undefined },
@@ -1,11 +1,13 @@
import { normalizeOpenaiReasoningEffort, type OpenaiReasoningEffort } from "../storage/types"
export interface ClaudeOpusAdaptiveThinkingSettings {
export interface ClaudeAdaptiveThinkingSettings {
enabled: boolean
effort?: OpenaiReasoningEffort
}
export function isClaudeOpusAdaptiveThinkingModel(modelId?: string): boolean {
export type ClaudeOpusAdaptiveThinkingSettings = ClaudeAdaptiveThinkingSettings
export function isClaudeAdaptiveThinkingModel(modelId?: string): boolean {
if (!modelId) {
return false
}
@@ -14,14 +16,20 @@ export function isClaudeOpusAdaptiveThinkingModel(modelId?: string): boolean {
const adaptiveVersions = ["4-6", "4.6", "4-7", "4.7", "4-8", "4.8"]
return (
id.includes("claude-fable-5") ||
id.includes("claude-sonnet-5") ||
id.includes("claude-5-sonnet") ||
id.includes("claude-opus-5") ||
id.includes("claude-5-opus") ||
adaptiveVersions.some((version) => id.includes(`claude-opus-${version}`) || id.includes(`claude-${version}-opus`))
)
}
export function resolveClaudeOpusAdaptiveThinking(
export const isClaudeOpusAdaptiveThinkingModel = isClaudeAdaptiveThinkingModel
export function resolveClaudeAdaptiveThinking(
reasoningEffort?: string,
legacyThinkingBudgetTokens?: number,
): ClaudeOpusAdaptiveThinkingSettings {
): ClaudeAdaptiveThinkingSettings {
if (reasoningEffort) {
const effort = normalizeOpenaiReasoningEffort(reasoningEffort)
return effort === "none" ? { enabled: false } : { enabled: true, effort }
@@ -30,6 +38,8 @@ export function resolveClaudeOpusAdaptiveThinking(
return legacyThinkingBudgetTokens && legacyThinkingBudgetTokens > 0 ? { enabled: true, effort: "high" } : { enabled: false }
}
export const resolveClaudeOpusAdaptiveThinking = resolveClaudeAdaptiveThinking
export function supportsReasoningEffortForModel(modelId?: string): boolean {
if (!modelId) {
return false
@@ -37,11 +47,20 @@ export function supportsReasoningEffortForModel(modelId?: string): boolean {
const id = modelId.toLowerCase()
return (
id.includes("deepseek") ||
id.includes("gemini") ||
id.includes("glm") ||
id.includes("gpt") ||
id.includes("kimi") ||
id.includes("mimo") ||
id.includes("minimax") ||
id.includes("moonshot") ||
id.startsWith("openai/o") ||
id.includes("/o") ||
id.startsWith("o") ||
id.includes("qwen") ||
id.includes("z-ai") ||
id.includes("zai") ||
id.includes("grok")
)
}
@@ -94,8 +94,8 @@ export const E2E_MOCK_CLINE_RECOMMENDED_MODELS = {
],
recommended: [
{
id: "anthropic/claude-sonnet-4.6",
name: "anthropic/claude-sonnet-4.6",
id: "anthropic/claude-sonnet-5",
name: "anthropic/claude-sonnet-5",
description: "Recommended model for e2e onboarding",
tags: ["BEST"],
},
@@ -123,12 +123,12 @@ export const E2E_MOCK_CLINE_MODELS = [
supported_parameters: [],
},
{
id: "anthropic/claude-sonnet-4.6",
name: "anthropic/claude-sonnet-4.6",
id: "anthropic/claude-sonnet-5",
name: "anthropic/claude-sonnet-5",
description: "Recommended model for e2e onboarding",
context_length: 200_000,
top_provider: {
max_completion_tokens: 64_000,
max_completion_tokens: 128_000,
context_length: 200_000,
is_moderated: false,
},
@@ -136,10 +136,10 @@ export const E2E_MOCK_CLINE_MODELS = [
modality: "text->text",
},
pricing: {
prompt: "0.000003",
completion: "0.000015",
input_cache_read: "0.0000003",
input_cache_write: "0.00000375",
prompt: "0.000002",
completion: "0.00001",
input_cache_read: "0.0000002",
input_cache_write: "0.0000025",
},
supported_parameters: ["include_reasoning"],
},
+3 -1
View File
@@ -20,7 +20,9 @@ describe("Cline Extension", () => {
})
it("should successfully execute the plus button command", async () => {
await new Promise((resolve) => setTimeout(resolve, 400))
const packageJSON = JSON.parse(await readFile(packagePath, "utf8"))
const id = packageJSON.publisher + "." + packageJSON.name
await vscode.extensions.getExtension(id)?.activate()
await vscode.commands.executeCommand("cline.plusButtonClicked")
})
@@ -1,11 +1,13 @@
import { describe, it } from "mocha"
import "should"
import type { ApiHandlerModel, ApiProviderInfo } from "@core/api"
import { isClaudeAdaptiveThinkingModel } from "@shared/utils/reasoning-support"
import {
GEMINI_FLASH_MAX_OUTPUT_TOKENS,
isClaude4PlusModelFamily,
isGeminiFlashModel,
isGLMModelFamily,
isGPT51PlusModel,
isGPT5ModelFamily,
isGptOssModelFamily,
isNativeToolCallingConfig,
@@ -13,6 +15,7 @@ import {
isPoolsideModelFamily,
modelDoesntSupportWebp,
shouldSkipReasoningForModel,
supportsReasoningEffortForModel,
} from "../model-utils"
// Minimal helper — modelDoesntSupportWebp only reads apiHandlerModel.id
@@ -37,6 +40,7 @@ describe("shouldSkipReasoningForModel", () => {
shouldSkipReasoningForModel("claude-3-sonnet").should.equal(false)
shouldSkipReasoningForModel("gpt-4").should.equal(false)
shouldSkipReasoningForModel("gemini-pro").should.equal(false)
shouldSkipReasoningForModel("zai/glm-5.2").should.equal(false)
})
it("should return false for undefined or empty model IDs", () => {
@@ -50,6 +54,31 @@ describe("shouldSkipReasoningForModel", () => {
})
})
describe("supportsReasoningEffortForModel", () => {
it("should return true for OpenRouter/Cline model families that use reasoning effort", () => {
for (const modelId of [
"zai/glm-5.2",
"z-ai/glm-5.2",
"cline-pass/glm-5.2",
"moonshotai/kimi-k2-thinking",
"kimi-k2-thinking",
"accounts/fireworks/models/kimi-k2p6",
"accounts/fireworks/models/minimax-m3",
"minimax/MiniMax-M2.7",
"provider/mimo-vl",
"qwen/qwen3.7-max",
"deepseek/deepseek-r1",
]) {
supportsReasoningEffortForModel(modelId).should.equal(true)
}
})
it("should return false for undefined and unrelated model IDs", () => {
supportsReasoningEffortForModel(undefined).should.equal(false)
supportsReasoningEffortForModel("anthropic/claude-sonnet-4.5").should.equal(false)
})
})
describe("isClaude4PlusModelFamily", () => {
it("should return true for Claude 4+ model IDs with version numbers", () => {
isClaude4PlusModelFamily("claude-sonnet-4-5-20250929").should.equal(true)
@@ -77,6 +106,18 @@ describe("isClaude4PlusModelFamily", () => {
})
})
describe("isClaudeAdaptiveThinkingModel", () => {
it("should return true for Claude Sonnet 5 IDs across provider naming variants", () => {
for (const modelId of [
"claude-sonnet-5",
"anthropic/claude-sonnet-5:1m",
"anthropic/claude-5-sonnet",
]) {
isClaudeAdaptiveThinkingModel(modelId).should.equal(true)
}
})
})
describe("isGPT5ModelFamily", () => {
it("should return true for GPT-5 model IDs with hyphen", () => {
isGPT5ModelFamily("gpt-5").should.equal(true)
@@ -105,6 +146,14 @@ describe("isGPT5ModelFamily", () => {
})
})
describe("isGPT51PlusModel", () => {
it("should recognize GPT-5.6 model ID variants", () => {
isGPT51PlusModel("gpt-5.6-sol").should.equal(true)
isGPT51PlusModel("gpt-5-6-terra").should.equal(true)
isGPT51PlusModel("openai/gpt-5.6-luna").should.equal(true)
})
})
describe("isGptOssModelFamily", () => {
it("should return true for gpt-oss model IDs", () => {
isGptOssModelFamily("gpt-oss-120b").should.equal(true)
@@ -142,6 +191,14 @@ describe("isPoolsideModelFamily", () => {
isNativeToolCallingConfig(providerInfo("openai-compatible", "poolside/laguna-m.1"), true).should.equal(true)
isNativeToolCallingConfig(providerInfo("openai-compatible", "poolside/laguna-m.1"), false).should.equal(false)
})
it("should qualify Kimi K2 and K3 models for next-gen and native tool calling paths", () => {
for (const modelId of ["moonshotai/kimi-k2", "kimi-k2-thinking", "moonshotai/kimi-k3", "kimi-k3"]) {
isNextGenModelFamily(modelId).should.equal(true)
isNativeToolCallingConfig(providerInfo("openrouter", modelId), true).should.equal(true)
isNativeToolCallingConfig(providerInfo("cline", modelId), true).should.equal(true)
}
})
})
describe("isGeminiFlashModel", () => {

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