Compare commits

...

58 Commits

Author SHA1 Message Date
Mikołaj Kondratek 7761370240 fix(context): require a context-specific message for Anthropic overflow (#12876)
checkIsAnthropicContextWindowError matched every nested Anthropic
invalid_request_error, but Anthropic uses that type for many conditions
unrelated to context size — invalid tool schema, oversized image, unknown
model id, out-of-range max_tokens, malformed messages.

Every sibling detector in this file gates on a status/type *and* a
context-specific message pattern; the Anthropic branch was the only one
that did not. Add the same message gate, reusing the overflow patterns
the Vercel and Bedrock branches already use for these Anthropic-authored
wire messages.

Both the provider message (error.error.message) and the SDK's own
message are checked, since the Anthropic SDK JSON-stringifies the
response body into APIError.message when that body has no top-level
message.

This changes recovery behavior, not just classification: an unrelated
invalid request no longer triggers handleContextWindowExceededError(),
which was truncating conversation history and retrying a request that
then failed again for the original reason.

The Anthropic branch had no test coverage, so the fixtures are net-new:
positive cases for each real overflow message shape (including one error
built by the Anthropic SDK itself) and negative cases for the unrelated
invalid_request_error payloads that previously matched.
2026-08-18 23:18:07 +02:00
Saoud Rizwan f2d2e15291 fix(telemetry): split user.auth_logged_out reasons so real involuntary logouts are measurable (#13182)
* fix(telemetry): split user.auth_logged_out reasons so real involuntary logouts are measurable

The single ERROR_RECOVERY reason conflated three very different situations:
startup with no stored Cline session (fired on every window open for API-key
users - not a logout at all), a refresh token rejected as invalid/expired
(a real involuntary logout), and restore throwing unexpectedly. Split them
into no_stored_session, token_invalid and restore_error, and stop emitting
anything when restore returns null transiently while the stored session is
still intact (refresh backoff, rollout stand-down).

* refactor(telemetry): simplify logout-reason split to plain per-call-site mapping

* fix(telemetry): classify rejected stored tokens as token_invalid, not no_stored_session

The provider swallowed AuthInvalidTokenError into null on the restore path, so users whose stored refresh token was rejected at startup - the exact involuntary-logout class this PR exists to measure - were binned as no_stored_session. The provider now rethrows invalid-token errors (the session is already cleared before the throw) and the restore catch classifies them as token_invalid.

* refactor(telemetry): make token_invalid detection observation-only via a provider breadcrumb

The previous fix rethrew AuthInvalidTokenError from the provider, which activated a previously-dead mid-session catch branch that clears auth state - a behavioral change in a telemetry PR. Reverted: the provider keeps its null-on-error contract exactly, and instead records a telemetry-only breadcrumb (lastRetrieveFailedWithInvalidToken) that the AuthService call sites read to pick token_invalid over no_stored_session. Tests assert state is untouched when the breadcrumb fires.

* fix(telemetry): classify swallowed restore failures as restore_error via the breadcrumb

The provider converted malformed stored auth data and unexpected restore errors
to null, so those machines were binned as no_stored_session and the restore_error
branch never fired for provider-internal failures. Generalize the telemetry-only
breadcrumb from a boolean to a failure kind (token_invalid | restore_error | null)
set on the malformed-data paths and the swallow-all catch; AuthService maps a null
result to the breadcrumb reason, falling back to no_stored_session. Behavior is
still unchanged (null-on-error contract intact); tests cover the provider-level
classification directly.

* refactor(telemetry): carry the retrieve outcome atomically and fix test telemetry flushing

Review feedback from dominiccooney and mkondratek:

- retrieveClineAuthInfo() now returns a discriminated result (success |
  no_stored_session | token_invalid | restore_error | session_retained)
  instead of ClineAuthInfo | null plus a mutable breadcrumb, so the value
  and the reason travel together and overlapping retrievals can't
  misattribute failures (also resolves greptile's shared-breadcrumb race).
- Every null outcome is mapped explicitly: rollout stand-down and
  refresh-retry cooldown are session_retained and emit no logout event
  instead of inflating no_stored_session; transient refresh failures
  surface as success carrying the stale stored data as before.
- The unreachable AuthInvalidTokenError/AuthNetworkError catch branches in
  internalGetAuthToken are removed along with the test that stubbed a
  rejection the provider cannot produce (it never throws by contract).
- The restore try is narrowed: a sendAuthStatusUpdate() failure after a
  successful restore no longer nulls the freshly-restored session or
  counts as restore_error.
- Tests pre-seed the telemetry singleton with a settled instance via a new
  injectTelemetryServiceForTest() hook, so fire-and-forget proxy promises
  settle deterministically against the stubs and never reach
  HostProvider after sandbox.restore (the Windows CI unhandled-rejection
  failure). Provider outcomes are now covered against the real
  implementation including the refresh path.

Verified: npx tsc --noEmit clean, mocha unit suite 1591 passing / 0 failing.

* revert(telemetry): drop the retrieve-result refactor, keep the telemetry-only reason mapping

Reduce the PR to pure telemetry changes with no behavioral risk: three
reason reassignments at the existing captureAuthLoggedOut call sites plus
the new LogoutReason values. The provider's control flow (throw/null/retry
semantics) is untouched.
2026-08-12 14:23:06 -07:00
Mikołaj Kondratek 7a55977265 fix(vscode): restore macOS E2E launch on legacy (#12875)
* fix(vscode): restore macOS E2E launch on legacy

Ports the fix from #12726 (main) to this branch. @vscode/test-electron
hardcodes the macOS entry point as
"Visual Studio Code.app/Contents/MacOS/Electron", but recent VS Code
stable builds ship that binary as "Code", so every macOS E2E spec fails
at launch with ENOENT before any extension code runs. Falls back to the
bundle's real executable name when the reported path is missing.

Test harness only — no product code touched.

* chore: retrigger checks (windows disk.test.ts flake)

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-10 13:05:58 -07:00
Saoud Rizwan 6d8703c894 feat(vscode): explain when a free model promotion ends (legacy) (#12971)
Once a free promotion ends, the cline-free/ model is removed from the
catalog and the backend answers 'model not found' to requests against it.
The CLI has shown a dedicated 'Free model promotion ended' banner for this
since #12593; the legacy extension instead classified the 404 into the
generic auth-status range and rendered a bare retry prompt with no
explanation.

Classify the case as its own ClineErrorType — gated on the cline-free/
prefix of the request's model id (which rides the serialized error
payload), so ordinary model-not-found errors keep their generic path —
and render a dedicated card in the webview with a button into the model
picker. The new type also joins the auto-retry skip lists (first chunk,
mid-stream, and subagent initial stream): a deleted model can never
answer, so users should not sit through three backoffs before the card
that tells them to pick another model.
2026-08-05 14:00:20 -07:00
Mikołaj Kondratek 1e3b23030c feat(telemetry): tag context-window overflow on legacy provider failure events (#12825)
* feat(telemetry): tag context-window overflow on legacy provider failure events

Adds errorClass ("context_window_exceeded" | "unknown") to
task.provider_api_error, using the same property name and values the SDK
extension attaches to this event so one query counts a class across both
rollout cohorts. Orthogonal to errorType, which covers account/transport
failures and drives retry control flow.

Also narrows the first-chunk overflow exclusion. Truncation-recovered
overruns take the handleContextWindowExceededError branch and never
reach the capture, so the extra guard only suppressed overruns that
survived a truncation and surface to the user. Those are now reported
and tagged instead of dropped; dashboards comparing cohort error rates
exclude error_class = 'context_window_exceeded'.

Telemetry only: no change to retry logic, recovery, or error UI.

* fix(telemetry): omit error_class metric attribute when unclassified

The OTel provider stringifies undefined attribute values, so passing
error_class through unconditionally bucketed unclassified failures under
a literal "undefined" instead of leaving the dimension absent. The event
path was already correct — the mid-stream call site omits the key
entirely rather than passing undefined.
2026-08-03 09:04:34 +02:00
Saoud Rizwan 75d06f95e1 Stand down Cline-account auth in legacy straggler windows (#12826)
* Stand down Cline-account auth in legacy straggler windows once the machine is promoted to the next bundle

During the combined-VSIX rollout, legacy and next keep Cline-account
credentials in different stores and /auth/refresh ROTATES the refresh
token: a still-open legacy window that refreshes after promotion
strands the next bundle with a consumed token (and vice versa),
producing the Unauthorized / re-authenticate / Missing-Authentication-
header cluster on extension_variant=next and mystery hard-logouts on
legacy.

Instead of syncing the two stores, make the legacy straggler stand
down: once the loader's cached cohort assignment says this machine
runs next on its next reload (and no override or crash pin forces
legacy), a legacy window never refreshes/rotates again. It keeps
working on its current access token until natural expiry, then shows
a one-time notice with a Reload Window button instead of refreshing,
and blocks new sign-ins from this window with the same guidance.

The stored credential blob is deliberately left untouched: the next
bundle's one-time migration reads it at first promotion, and a
demotion back to legacy must find it intact.

Inert outside combined rollout builds (gated on the build-time
CLINE_ROLLOUT_VARIANT=legacy stamp), so standalone/marketplace and
dev builds never change behavior even with a leftover loader memento.

* Gate the stand-down on next actually holding the Cline credentials

A cohort assignment alone is not a hazard: the loader caches the flag
result mid-session, so a user's one and only legacy window could stand
down (and eventually sign out at token expiry) before the next bundle
ever ran on the machine. Rotation stranding needs a second holder of the
token family, and that holder is born when next's credential migration
(or a sign-in on next) writes a cline entry with a refresh token into
providers.json.

shouldStandDownAuth() now additionally probes
<CLINE_DATA_DIR|CLINE_DIR/data|~/.cline/data>/settings/providers.json
(mirroring the SDK's resolveDataDirFromEnv) for
providers.cline.settings.auth.refreshToken, re-read on every check like
the cohort memento: a promoted-but-not-yet-reloaded single window keeps
signing in and refreshing indefinitely; the moment a next window
activates alongside it the straggler stands down without a reload; and
if the entry disappears again the window resumes auth on its own. A
cline entry without auth (e.g. the CLI configured a model but never
signed in) does not count, and any read/parse failure fails open.

(--no-verify: the only remaining pre-commit error is the pre-existing
noShadowRestrictedNames on the proto String import in AuthService.ts.)

* Use the host bridge for the stand-down notice and inject the reload action

Direct vscode.window/vscode.commands calls outside extension.ts violate
the host-bridge lint rules (src/dev/grit/vscode-api.grit) and fail CI.
Show the notice via HostProvider.window.showMessage and inject the
workbench.action.reloadWindow call from extension.ts, where direct
VS Code APIs are allowed.
2026-08-01 11:19:42 -07:00
Saoud Rizwan b86f9279c3 feat(telemetry): report legacy provider API failures with SDK-extension-parity schema (#12812)
* feat(telemetry): report provider API failures with SDK-extension-parity schema

* fix(telemetry): don't double-count declined first-chunk retries in the stream catch

* feat(telemetry): mark provider API failures as terminal vs auto-retried attempts

* refactor(telemetry): rename provider-failure 'terminal' flag to 'fatal' (terminal is the shell in Cline)

* refactor(telemetry): report only user-surfaced provider failures, drop the fatal flag

Auto-retried attempts emit nothing on any site (including the pre-existing empty-response site), matching the SDK extension whose provider layer retries transients silently. Both cohorts now report the same thing by construction - no query-side filtering needed.

* fix(telemetry): don't report user cancels as provider failures
2026-07-31 23:54:05 -07:00
Saoud Rizwan 7de9c46ebf Show Legacy/Next extension variant in the settings About page (#12776) 2026-07-30 21:56:56 -07:00
Tomás Barreiro 3fdc186f18 Update the cline free model button wording in the legacy extension (#12668)
* Update the cline free model button wording in the legacy extension

* fix tests
2026-07-29 01:19:33 +02:00
Saoud Rizwan f267bf47e3 chore(vscode): bump to 4.0.12 for legacy release 2026-07-28 15:44:07 -07:00
Tomás Barreiro 5dd9d57e42 Suport cline-free models in the legacy extension (#12661)
* Suport cline-free models in the legacy extension

* fix retry display

* fix ci

* Fix model info resolution
2026-07-29 00:15:35 +02:00
Mikołaj Kondratek 9701539dfd fix(vscode): suppress Claude Code max-turns exit code when response already yielded (legacy) (#12610)
* fix: suppress Claude Code max_turns exit code when response already yielded

When using Claude Code with --max-turns 1, some models produce native
tool_use responses instead of XML text, causing Claude Code to exit with
code 1 (Reached maximum number of turns). The response is already
streamed and yielded by that point, so the exit code 1 should not
discard the valid response.

Changes:
- Track hasReceivedResult and suppress exit code 1 when result already yielded
- Add missing tools to disallowed list (PowerShell, Monitor, PushNotification, etc.)
- Improve error capture using execa error properties for better diagnostics

* test: cover exit-code-1 suppression with and without a streamed result chunk

---------

Co-authored-by: Mathis1337 <20599614+mathis1337@users.noreply.github.com>
2026-07-28 06:12:00 +02:00
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 Kondratek 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
Max 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 Taboada 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 Barreiro 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
Max 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 Barreiro 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
115 changed files with 5441 additions and 506 deletions
+1
View File
@@ -8,6 +8,7 @@ on:
pull_request:
branches:
- main
- legacy-extension
workflow_call:
# Set default permissions for all jobs
+110
View File
@@ -1,5 +1,115 @@
# Changelog
## [4.0.12]
### Added
- Add support for free Cline models, shown as "(free)" in the model picker, with a dedicated error card that includes the reset time when the free limit is reached.
### Fixed
- Keep Claude Code responses that were already streamed when the CLI exits with a max-turns error, instead of discarding a valid response.
## [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
@@ -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");
});
});
+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.12",
"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",
+7 -2
View File
@@ -9,6 +9,7 @@ import { ClineAccountService } from "@/services/account/ClineAccountService"
import { AuthService } from "@/services/auth/AuthService"
import { buildClineExtraHeaders } from "@/services/EnvUtils"
import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "@/shared/ClineAccount"
import { isClineFreeModelId } from "@/shared/cline/free-models"
import { CLINE_RECOMMENDED_MODELS_FALLBACK } from "@/shared/cline/recommended-models"
import type { ClineStorageMessage } from "@/shared/messages/content"
import { fetch, getAxiosSettings } from "@/shared/net"
@@ -42,6 +43,10 @@ const CLINE_FREE_MODEL_IDS = new Set([
...Object.keys(clinePassModels).map((modelId) => normalizeModelId(modelId)),
])
function isFreeClineModel(modelId: string, freeModelIds: Set<string>): boolean {
return isClineFreeModelId(modelId) || freeModelIds.has(normalizeModelId(modelId))
}
function getCacheReadTokens(usage: any): number {
return usage?.prompt_tokens_details?.cached_tokens || usage?.cache_read_input_tokens || 0
}
@@ -242,7 +247,7 @@ export class ClineHandler implements ApiHandler {
// @ts-expect-error-next-line
let totalCost = (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0)
const modelId = this.getModel().id
const isFreeModel = freeModelIds.has(normalizeModelId(modelId))
const isFreeModel = isFreeClineModel(modelId, freeModelIds)
const cacheReadTokens = getCacheReadTokens(chunk.usage)
const cacheWriteTokens = getCacheWriteTokens(chunk.usage)
@@ -299,7 +304,7 @@ export class ClineHandler implements ApiHandler {
const generation = response.data
let totalCost = generation?.total_cost || 0
const modelId = this.getModel().id
const isFreeModel = resolvedFreeModelIds.has(normalizeModelId(modelId))
const isFreeModel = isFreeClineModel(modelId, resolvedFreeModelIds)
if (isFreeModel) {
totalCost = 0
+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],
+23 -2
View File
@@ -26,6 +26,26 @@ export class RetriableError extends Error {
}
}
const INFERENCE_CAP_ERROR_CODE = "INFERENCE_CAP_ERROR"
/**
* Inference cap errors mean the user is out of quota until it resets, so retrying
* only burns backoff. Providers surface the code in a few shapes: directly on the
* error, nested under `error`/`details`, or embedded in a wrapped message string
* (see ClineHandler, which rethrows stream errors as `Cline API Error <code>: ...`).
*/
function isInferenceCapError(error: any): boolean {
if (
error?.code === INFERENCE_CAP_ERROR_CODE ||
error?.error?.code === INFERENCE_CAP_ERROR_CODE ||
error?.details?.code === INFERENCE_CAP_ERROR_CODE
) {
return true
}
return typeof error?.message === "string" && error.message.includes(INFERENCE_CAP_ERROR_CODE)
}
export function withRetry(options: RetryOptions = {}) {
const { maxRetries, baseDelay, maxDelay, retryAllErrors } = { ...DEFAULT_OPTIONS, ...options }
@@ -40,8 +60,9 @@ export function withRetry(options: RetryOptions = {}) {
} catch (error: any) {
const isRateLimit = error?.status === 429 || error instanceof RetriableError
const isLastAttempt = attempt === maxRetries - 1
if ((!isRateLimit && !retryAllErrors) || isLastAttempt) {
// We shouldn't retry cap limits because they mean the user needs to wait for longer
const isCapLimitError = isInferenceCapError(error)
if ((!isRateLimit && !retryAllErrors) || isLastAttempt || isCapLimitError) {
throw error
}
@@ -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,5 +1,6 @@
import { APIError } from "@anthropic-ai/sdk"
import { expect } from "chai"
import { checkContextWindowExceededError } from "../context-error-handling"
import { checkContextWindowExceededError, checkIsAnthropicContextWindowError } from "../context-error-handling"
describe("checkContextWindowExceededError", () => {
it("detects OpenRouter context errors using structured status", () => {
@@ -28,4 +29,142 @@ describe("checkContextWindowExceededError", () => {
expect(checkContextWindowExceededError(error)).to.equal(false)
})
it("classifies a real Anthropic overflow error as a context window failure", () => {
expect(
checkContextWindowExceededError(makeAnthropicError("prompt is too long: 213462 tokens > 200000 maximum")),
).to.equal(true)
})
it("does not classify an unrelated Anthropic invalid_request_error as a context window failure", () => {
const error = makeAnthropicError("tools.0.custom.input_schema: Extra inputs are not permitted")
expect(checkContextWindowExceededError(error)).to.equal(false)
})
})
/**
* Builds the shape an Anthropic API rejection has by the time it reaches the detector: the SDK's
* `APIError` carries the parsed response body on `error`, so the provider's own error object is
* nested at `error.error`.
*/
function makeAnthropicError(message: string, type = "invalid_request_error", status = 400) {
return {
status,
message: `${status} ${JSON.stringify({ type: "error", error: { type, message } })}`,
error: {
type: "error",
error: { type, message },
},
}
}
describe("checkIsAnthropicContextWindowError", () => {
describe("real overflow rejections still auto-truncate", () => {
// Anthropic's current overflow message, emitted when the rendered prompt alone exceeds the
// model's context window.
it("matches 'prompt is too long' with the token counts", () => {
expect(
checkIsAnthropicContextWindowError(makeAnthropicError("prompt is too long: 213462 tokens > 200000 maximum")),
).to.equal(true)
})
// Emitted when the prompt fits but prompt + max_tokens does not.
it("matches 'input length and max_tokens exceed context limit'", () => {
const error = makeAnthropicError(
"input length and max_tokens exceed context limit: 197000 + 8192 > 200000, decrease input length or max_tokens and try again",
)
expect(checkIsAnthropicContextWindowError(error)).to.equal(true)
})
it("matches the older 'input is too long' phrasing", () => {
expect(checkIsAnthropicContextWindowError(makeAnthropicError("input is too long for requested model"))).to.equal(true)
})
it("matches when only the SDK's JSON-stringified message carries the overflow text", () => {
const error = makeAnthropicError("prompt is too long: 213462 tokens > 200000 maximum")
// Some wrappers hand us the error object without the provider message on the inner error.
delete (error.error.error as { message?: string }).message
expect(checkIsAnthropicContextWindowError(error)).to.equal(true)
})
it("matches an error constructed by the Anthropic SDK itself", () => {
const body = {
type: "error",
error: { type: "invalid_request_error", message: "prompt is too long: 213462 tokens > 200000 maximum" },
}
const error = APIError.generate(400, body, undefined, new Headers())
expect(checkIsAnthropicContextWindowError(error)).to.equal(true)
})
})
describe("unrelated invalid_request_error rejections are not overflows", () => {
// Each of these previously matched on the error type alone, triggering a conversation
// truncation and a retry that then failed again for the original reason.
const UNRELATED_MESSAGES = [
"tools.0.custom.input_schema: Extra inputs are not permitted",
"messages.1.content.0.image.source.base64: image exceeds 5 MB maximum: 6291456 bytes > 5242880 bytes",
"model: claude-not-a-real-model",
"max_tokens: 100000 > 64000, which is the maximum allowed number of output tokens for claude-sonnet-4-5",
"messages: at least one message is required",
"messages.0: all messages must have non-empty content except for the optional final assistant message",
"temperature: Input should be less than or equal to 1",
]
for (const message of UNRELATED_MESSAGES) {
it(`does not match: ${message}`, () => {
expect(checkIsAnthropicContextWindowError(makeAnthropicError(message))).to.equal(false)
})
}
it("does not match an invalid_request_error with no message at all", () => {
expect(
checkIsAnthropicContextWindowError({ error: { type: "error", error: { type: "invalid_request_error" } } }),
).to.equal(false)
})
it("does not match an invalid_request_error with a non-string message", () => {
const error = makeAnthropicError("unused")
;(error.error.error as { message?: unknown }).message = { detail: "structured" }
error.message = ""
expect(checkIsAnthropicContextWindowError(error)).to.equal(false)
})
})
describe("non-invalid_request_error inputs", () => {
it("does not match other Anthropic error types even with overflow-shaped text", () => {
const error = makeAnthropicError("prompt is too long: 213462 tokens > 200000 maximum", "rate_limit_error", 429)
expect(checkIsAnthropicContextWindowError(error)).to.equal(false)
})
it("does not match an overloaded_error", () => {
expect(checkIsAnthropicContextWindowError(makeAnthropicError("Overloaded", "overloaded_error", 529))).to.equal(false)
})
for (const [label, value] of [
["null", null],
["undefined", undefined],
["a plain string", "prompt is too long: 213462 tokens > 200000 maximum"],
["an object with no error property", { message: "prompt is too long: 213462 tokens > 200000 maximum" }],
] as const) {
it(`does not match ${label}`, () => {
expect(checkIsAnthropicContextWindowError(value)).to.equal(false)
})
}
it("does not throw when property access throws", () => {
const error = {
get error(): never {
throw new Error("boom")
},
}
expect(checkIsAnthropicContextWindowError(error)).to.equal(false)
})
})
})
@@ -58,9 +58,29 @@ function checkIsOpenAIContextWindowError(error: unknown): boolean {
}
}
function checkIsAnthropicContextWindowError(response: any): boolean {
export function checkIsAnthropicContextWindowError(response: any): boolean {
try {
return response?.error?.error?.type === "invalid_request_error"
// Anthropic returns `invalid_request_error` for many conditions that have nothing to do with
// context size (malformed params, invalid tool schema, oversized image, unknown model id), so
// the error type alone cannot identify an overflow — it needs a context-specific message too.
if (response?.error?.error?.type !== "invalid_request_error") {
return false
}
// The Anthropic SDK puts the response body on `error` and, when that body has no top-level
// `message`, JSON-stringifies it into the APIError's own `message` — so either path can carry
// the overflow text depending on how the error reached us.
const messages: unknown[] = [response?.error?.error?.message, response?.message].filter((msg) => msg != null)
// Anthropic-authored overflow wire messages, matching the patterns the Vercel and Bedrock
// branches in this file already use for the same provider messages.
const CONTEXT_ERROR_PATTERNS = [
/prompt is too long.*tokens?\s*>\s*\d+\s*maximum/i,
/input is too long/i,
/input length and max_tokens exceed context limit/i,
] as const
return messages.some((msg) => CONTEXT_ERROR_PATTERNS.some((pattern) => pattern.test(String(msg))))
} catch {
return false
}
@@ -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,
};
}
}
+5 -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"
@@ -34,6 +35,7 @@ import { BannerService } from "@/services/banner/BannerService"
import { featureFlagsService } from "@/services/feature-flags"
import { getDistinctId } from "@/services/logging/distinctId"
import { telemetryService } from "@/services/telemetry"
import { getExtensionVariant } from "@/services/telemetry/rollout-metadata"
import { ClineExtensionContext } from "@/shared/cline"
import { getAxiosSettings } from "@/shared/net"
import { ShowMessageType } from "@/shared/proto/host/window"
@@ -523,8 +525,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 +857,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")
@@ -928,6 +929,7 @@ export class Controller {
return {
version,
extensionVariant: getExtensionVariant(),
apiConfiguration,
currentTaskItem,
clineMessages,
@@ -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"],
},
@@ -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"],
},
@@ -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"]);
});
});
@@ -15,13 +15,16 @@ import {
CLAUDE_OPUS_1M_TIERS,
CLAUDE_SONNET_1M_TIERS,
openRouterClaudeFable51mModelId,
openRouterClaudeOpus51mModelId,
openRouterClaudeOpus461mModelId,
openRouterClaudeOpus471mModelId,
openRouterClaudeOpus481mModelId,
openRouterClaudeSonnet41mModelId,
openRouterClaudeSonnet51mModelId,
openRouterClaudeSonnet451mModelId,
openRouterClaudeSonnet461mModelId,
} from "@/shared/api"
import { formatClineFreeModelName, isClineFreeModelId, zeroPricedModelInfo } from "@/shared/cline/free-models"
import { getAxiosSettings } from "@/shared/net"
import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
import { Logger } from "@/shared/services/Logger"
@@ -87,6 +90,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 +141,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 +215,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 +241,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":
@@ -266,6 +318,16 @@ async function fetchAndCacheClineModels(): Promise<Record<string, ModelInfo>> {
)
}
// cline-free/ models are promotional: they always bill at $0 and are marked
// "(free)" so users can tell them apart from the paid model of the same slug.
if (isClineFreeModelId(rawModel.id)) {
models[rawModel.id] = zeroPricedModelInfo({
...modelInfo,
name: formatClineFreeModelName(rawModel.id, modelInfo.name),
})
continue
}
models[rawModel.id] = modelInfo
// Add custom :1m model variant for Sonnet models
@@ -273,12 +335,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 +376,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 +392,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 +414,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,
}
+147 -23
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 };
}
@@ -2472,6 +2467,22 @@ export class Task {
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,
);
// Daily Cline free-model limits reset in hours — same reasoning as
// ClinePass limits: surface the actionable UI instead of retrying.
const isClineFreeModelLimitError = clineError.isErrorType(
ClineErrorType.ClineFreeModelLimit,
);
// A retired free model can never answer — its id was removed from
// the catalog when the promotion ended — so retrying only delays
// the card that routes the user into the model picker.
const isClineFreePromotionEndedError = clineError.isErrorType(
ClineErrorType.ClineFreePromotionEnded,
);
// Check if this is a Cline provider insufficient credits error - don't auto-retry these
const isClineProviderInsufficientCredits = (() => {
@@ -2499,7 +2510,47 @@ export class Task {
!quotaExceeded &&
!isEntitlementError &&
!isOrgClinePassRestrictionError &&
!isClinePassLimitError &&
!isClineFreeModelLimitError &&
!isClineFreePromotionEndedError &&
this.taskState.autoRetryAttempts < 3;
// Mirror the SDK extension's provider-failure reporting: same
// errorType/failurePhase schema so error rates are comparable
// across the A/B rollout cohorts. Only failures that actually
// surface to the user are reported — attempts an auto-retry
// absorbs emit nothing, matching the SDK extension, whose
// provider layer retries transients silently before any event
// exists. Truncation-recovered context-window overruns are
// likewise unreported: they take the branch above and never
// reach here, so reaching this point with an overflow means the
// overrun survived a truncation and is surfacing to the user.
// It is reported and tagged (see errorClass) rather than
// dropped, so it stays countable without inflating the
// cohort error rate — dashboards comparing cohorts exclude
// error_class = 'context_window_exceeded'.
// (`abort` gate: a user cancel unwinds through this catch as a
// generic "Cline instance aborted" error — never a provider
// failure, and the SDK extension doesn't report cancels either.)
if (!shouldRetry && !this.taskState.abort) {
telemetryService.captureProviderApiError({
ulid: this.ulid,
model: model.id,
provider: providerId,
errorMessage: clineError.message,
errorStatus: clineError._error?.status,
requestId: clineError._error?.request_id,
errorType: ClineError.getErrorType(clineError),
failurePhase: "streaming",
// A failure awaiting the provider's first chunk is
// definitionally a provider failure, so it is always
// classified.
errorClass: isContextWindowExceededError
? "context_window_exceeded"
: "unknown",
});
}
if (shouldRetry) {
// Auto-retry enabled with max 3 attempts: automatically approve the retry
this.taskState.autoRetryAttempts++;
@@ -2562,7 +2613,10 @@ export class Task {
!isSpendLimitError &&
!quotaExceeded &&
!isEntitlementError &&
!isOrgClinePassRestrictionError;
!isOrgClinePassRestrictionError &&
!isClinePassLimitError &&
!isClineFreeModelLimitError &&
!isClineFreePromotionEndedError;
if (showRetry) {
await this.say(
"error_retry",
@@ -2825,6 +2879,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 =
@@ -2847,9 +2911,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
@@ -3579,14 +3641,71 @@ export class Task {
this.api.getModel().id,
);
const errorMessage = clineError.serialize();
const isStreamingSpendLimitError = clineError.isErrorType(
ClineErrorType.SpendLimit,
);
// Auto-retry for streaming failures (skip for spend limit errors)
// Inference cap and daily free-model limits reset in hours, so
// retrying only burns backoff before surfacing the actionable UI.
// Mirrors the non-streaming gating in attemptApiRequest.
const isStreamingQuotaExceededError = clineError.isErrorType(
ClineErrorType.QuotaExceeded,
);
const isStreamingClineFreeModelLimitError = clineError.isErrorType(
ClineErrorType.ClineFreeModelLimit,
);
// A retired free model (promotion ended) can never answer, so a
// retry against it is guaranteed to fail again.
const isStreamingClineFreePromotionEndedError =
clineError.isErrorType(ClineErrorType.ClineFreePromotionEnded);
const isStreamingNonRetriableError =
isStreamingSpendLimitError ||
isStreamingQuotaExceededError ||
isStreamingClineFreeModelLimitError ||
isStreamingClineFreePromotionEndedError;
const willAutoRetryStreamingFailure =
!isStreamingNonRetriableError &&
this.taskState.autoRetryAttempts < 3;
// Mirror the SDK extension's provider-failure reporting for
// mid-stream failures (see attemptApiRequest for the
// first-chunk equivalent). Only failures that surface to
// the user are reported — auto-retried attempts emit
// nothing, matching the SDK extension.
// isWaitingForFirstChunk gate: first-chunk failures are
// already reported inside attemptApiRequest's catch, and a
// declined retry rethrows a generic error that unwinds to
// this catch — the flag is only cleared after the first
// chunk yields, so it cleanly excludes those re-thrown
// pre-stream failures from being counted twice.
if (
!isStreamingSpendLimitError &&
this.taskState.autoRetryAttempts < 3
!this.taskState.isWaitingForFirstChunk &&
!willAutoRetryStreamingFailure &&
!this.taskState.abort
) {
const { providerId: midStreamProviderId } =
this.getCurrentProviderInfo();
telemetryService.captureProviderApiError({
ulid: this.ulid,
model: this.api.getModel().id,
provider: midStreamProviderId,
errorMessage: clineError.message,
errorStatus: clineError._error?.status,
requestId: clineError._error?.request_id,
errorType: ClineError.getErrorType(clineError),
failurePhase: "streaming",
// This catch also wraps post-stream processing, so a
// failure here is not necessarily a provider error;
// only a positive detector match is a confident
// classification.
...(checkContextWindowExceededError(error)
? { errorClass: "context_window_exceeded" as const }
: {}),
});
}
// Auto-retry for streaming failures (skip for non-retriable errors)
if (willAutoRetryStreamingFailure) {
this.taskState.autoRetryAttempts++;
// Calculate exponential backoff for streaming failures: 2s, 4s, 8s
@@ -3618,7 +3737,7 @@ export class Task {
}
});
} else if (
!isStreamingSpendLimitError &&
!isStreamingNonRetriableError &&
this.taskState.autoRetryAttempts >= 3
) {
// Show error_retry with failed flag to indicate all retries exhausted
@@ -3826,15 +3945,20 @@ export class Task {
const { model, providerId } = this.getCurrentProviderInfo();
const reqId = this.getApiRequestIdSafe();
// Minimal diagnostics: structured log and telemetry
telemetryService.captureProviderApiError({
ulid: this.ulid,
model: model.id,
provider: providerId,
errorMessage: "empty_assistant_message",
requestId: reqId,
isNativeToolCall: this.useNativeToolCalls,
});
// Minimal diagnostics: structured log and telemetry. Reported
// only when no auto-retry follows, consistent with the other
// provider-failure sites — occurrences an auto-retry absorbs
// emit nothing.
if (this.taskState.autoRetryAttempts >= 3) {
telemetryService.captureProviderApiError({
ulid: this.ulid,
model: model.id,
provider: providerId,
errorMessage: "empty_assistant_message",
requestId: reqId,
isNativeToolCall: this.useNativeToolCalls,
});
}
const baseErrorMessage =
"Invalid API Response: The provider returned an empty or unparsable response. This is a provider-side issue where the model failed to generate valid output or returned tool calls that Cline cannot process. Retrying the request may help resolve this issue.";
@@ -879,12 +879,18 @@ export class SubagentRunner {
const isOrgClinePassRestrictionError = parsedError.isErrorType(
ClineErrorType.OrgClinePassRestriction,
);
// A retired free model (promotion ended) can never answer, so a retry
// against it is guaranteed to fail again.
const isClineFreePromotionEndedError = parsedError.isErrorType(
ClineErrorType.ClineFreePromotionEnded,
);
if (
isAuthError ||
isBalanceError ||
isEntitlementError ||
isOrgClinePassRestrictionError
isOrgClinePassRestrictionError ||
isClineFreePromotionEndedError
) {
return false;
}
+14
View File
@@ -51,12 +51,18 @@ import { VscodeWebviewProvider } from "./hosts/vscode/VscodeWebviewProvider"
import { exportVSCodeStorageToSharedFiles } from "./hosts/vscode/vscode-to-file-migration"
import { ExtensionRegistryInfo } from "./registry"
import { AuthService } from "./services/auth/AuthService"
import { initializeRolloutStanddown } from "./services/auth/rollout-standdown"
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.
@@ -67,6 +73,14 @@ export async function activate(context: vscode.ExtensionContext) {
// IMPORTANT: This must be done before any service can be registered
setupHostProvider(context)
// Give the rollout stand-down check access to the loader's cohort memento
// (no-op outside combined rollout builds — see rollout-standdown.ts). The
// reload action is injected here because direct vscode.commands usage is
// restricted to this file by the host-bridge lint rules.
initializeRolloutStanddown(context, () => {
vscode.commands.executeCommand("workbench.action.reloadWindow")
})
// 2. Clean up legacy data patterns within VSCode's native storage.
// Moves workspace→global keys, task history→file, custom instructions→rules, etc.
// Must run BEFORE the file export so we copy clean state.
@@ -3,7 +3,15 @@ import path from "path"
import proxyquire from "proxyquire"
import sinon from "sinon"
const DEFAULT_OUTPUT_LINES = ['{"type":"text","text":"Hello"}', '{"type":"text","text":" world"}']
// Mutable state the mock factories read, so individual tests can simulate
// different CLI output streams and exit results.
let mockOutputLines = DEFAULT_OUTPUT_LINES
let mockProcessError: Error | null = null
const createMockProcess = () => {
const exitCode = mockProcessError ? ((mockProcessError as any).exitCode ?? 1) : 0
const mockProcess = {
stdin: {
write: sinon.fake(),
@@ -18,15 +26,20 @@ const createMockProcess = () => {
},
on: sinon.fake((event, callback) => {
if (event === "close") {
setImmediate(() => callback(0))
setImmediate(() => callback(exitCode))
}
if (event === "error") {
}
}),
killed: false,
kill: sinon.fake(),
exitCode: 0,
then: (onResolve: (value: any) => void) => {
exitCode,
then: (onResolve: (value: any) => void, onReject?: (reason: any) => void) => {
// execa's process promise rejects on nonzero exit (default `reject: true`)
if (mockProcessError && onReject) {
setImmediate(() => onReject(mockProcessError))
return Promise.resolve()
}
setImmediate(() => onResolve({ exitCode: 0 }))
return Promise.resolve({ exitCode: 0 })
},
@@ -42,9 +55,8 @@ const createMockProcess = () => {
const createMockReadlineInterface = () => {
const mockInterface = {
async *[Symbol.asyncIterator]() {
// Simulate Claude CLI JSON output - yield a few chunks then end
yield '{"type":"text","text":"Hello"}'
yield '{"type":"text","text":" world"}'
// Simulate Claude CLI JSON output - yield the configured chunks then end
yield* mockOutputLines
// Iterator ends naturally when function returns
return
},
@@ -79,6 +91,8 @@ describe("Claude Code Integration", () => {
afterEach(() => {
sinon.restore()
mockOutputLines = DEFAULT_OUTPUT_LINES
mockProcessError = null
})
const itCallsTheScriptWithAFile = (systemPrompt: string) => {
@@ -160,4 +174,60 @@ describe("Claude Code Integration", () => {
})
})
})
describe("when the process exits with code 1", () => {
// Mimics execa's rejection: an Error carrying exitCode, whose message
// includes the full command after ": "
const createExitCodeOneError = () => {
const error = new Error("Command failed with exit code 1: claude --system-prompt aaa --verbose")
;(error as any).exitCode = 1
return error
}
const collectChunks = async () => {
const chunks: unknown[] = []
for await (const chunk of runClaudeCode({
systemPrompt: "test",
messages: [],
modelId: "test",
path: scriptPath,
})) {
chunks.push(chunk)
}
return chunks
}
describe("after a result chunk was streamed (max-turns exit)", () => {
beforeEach(() => {
mockOutputLines = [
'{"type":"assistant","message":{"content":[{"type":"text","text":"Hello"}]}}',
'{"type":"result","subtype":"success","is_error":false,"num_turns":1}',
]
mockProcessError = createExitCodeOneError()
})
it("suppresses the error and yields the full response", async () => {
const chunks = await collectChunks()
expect(chunks).to.have.length(2)
expect((chunks[1] as { type: string }).type).to.equal("result")
})
})
describe("without a result chunk", () => {
beforeEach(() => {
mockOutputLines = ['{"type":"assistant","message":{"content":[{"type":"text","text":"Hello"}]}}']
mockProcessError = createExitCodeOneError()
})
it("throws", async () => {
try {
await collectChunks()
expect.fail("expected runClaudeCode to throw")
} catch (error) {
expect((error as Error).message).to.include("Command failed with exit code 1")
}
})
})
})
})
@@ -55,6 +55,13 @@ export async function* runClaudeCode(options: ClaudeCodeOptions): AsyncGenerator
partialData: null,
}
// Track whether we've received a result chunk from Claude Code.
// When --max-turns 1 is set and the model produces a tool_use response (native tool calling),
// Claude Code exits with code 1 ("Reached maximum number of turns"). But by that point,
// the assistant message and result have already been streamed and yielded. In that case,
// the exit code 1 is expected and we should not throw.
let hasReceivedResult = false
try {
cProcess.stderr.on("data", (data) => {
processState.stderrLogs += data.toString()
@@ -80,6 +87,11 @@ export async function* runClaudeCode(options: ClaudeCodeOptions): AsyncGenerator
continue
}
// Track result chunks so we know the response is complete
if (typeof chunk !== "string" && chunk.type === "result") {
hasReceivedResult = true
}
yield chunk
}
}
@@ -98,9 +110,30 @@ export async function* runClaudeCode(options: ClaudeCodeOptions): AsyncGenerator
)
}
} catch (err) {
// When --max-turns 1 is set and the model uses native tool calling instead of
// XML-formatted text tool calls, Claude Code exits with code 1 ("Reached maximum
// number of turns"). If we already received and yielded the result chunk, the
// response is complete — suppress the error and let the generator end normally.
if (hasReceivedResult && (err as any)?.exitCode === 1) {
Logger.log("Claude Code exited with max_turns limit — response already yielded, suppressing error.")
return
}
Logger.error(`Error during Claude Code execution:`, err)
if (processState.stderrLogs.includes("unknown option '--system-prompt-file'")) {
// Collect all available error details from multiple sources.
// execa attaches stderr/stdout to the error object, and we also capture stderr via our stream listener.
const execaStderr = (err as any)?.stderr?.trim?.() || ""
const execaStdout = (err as any)?.stdout?.trim?.() || ""
const collectedStderr = processState.stderrLogs?.trim() || ""
// Use the most informative stderr source available
const stderrContent = collectedStderr || execaStderr
Logger.error(
`Claude Code error details — stderr: ${stderrContent || "(empty)"}, stdout tail: ${execaStdout?.slice?.(-500) || "(empty)"}`,
)
if (stderrContent.includes("unknown option '--system-prompt-file'")) {
throw new Error(`The Claude Code executable is outdated. Please update it to the latest version.`, {
cause: err,
})
@@ -141,7 +174,13 @@ Anthropic is aware of this issue and is considering a fix: https://github.com/an
if (startOfCommand !== -1) {
const messageWithoutCommand = err.message.slice(0, startOfCommand).trim()
throw new Error(`${messageWithoutCommand}\n${processState.stderrLogs?.trim()}`, { cause: err })
// Build a descriptive error message using all available error sources
const errorDetails = stderrContent || execaStdout?.slice?.(-500) || ""
const errorSuffix = errorDetails
? `\n${errorDetails}`
: "\nNo error details available. Check the Output panel (Cline) for more information."
throw new Error(`${messageWithoutCommand}${errorSuffix}`, { cause: err })
}
}
@@ -164,7 +203,9 @@ Anthropic is aware of this issue and is considering a fix: https://github.com/an
const claudeCodeTools = [
"Task",
"TaskOutput",
"TaskStop",
"Bash",
"PowerShell",
"Glob",
"Grep",
"Read",
@@ -174,9 +215,10 @@ const claudeCodeTools = [
"WebFetch",
"TodoWrite",
"WebSearch",
"TaskStop",
"AskUserQuestion",
"Skill",
"Monitor",
"ToolSearch",
"EnterPlanMode",
"ExitPlanMode",
"EnterWorktree",
@@ -184,7 +226,9 @@ const claudeCodeTools = [
"CronCreate",
"CronDelete",
"CronList",
"ToolSearch",
"PushNotification",
"RemoteTrigger",
"ScheduleWakeup",
].join(",")
const CLAUDE_CODE_TIMEOUT = 600000 // 10 minutes
+16 -3
View File
@@ -12,6 +12,7 @@ import { BannerService } from "../banner/BannerService"
import { AuthInvalidTokenError, AuthNetworkError } from "../error/ClineError"
import { featureFlagsService } from "../feature-flags"
import { ClineAuthProvider } from "./providers/ClineAuthProvider"
import { notifyRolloutStanddown, shouldStandDownAuth } from "./rollout-standdown"
import { LogoutReason } from "./types"
export type ServiceConfig = {
@@ -187,7 +188,7 @@ export class AuthService {
Logger.error("Token is invalid or expired:", error)
this._clineAuthInfo = null
this._authenticated = false
telemetryService.captureAuthLoggedOut(this._provider.name, LogoutReason.ERROR_RECOVERY)
telemetryService.captureAuthLoggedOut(this._provider.name, LogoutReason.TOKEN_INVALID)
authStatusChanged = true
} else if (error instanceof AuthNetworkError) {
Logger.error("Network error refreshing token", error)
@@ -258,6 +259,16 @@ export class AuthService {
return String.create({ value: "Already authenticated" })
}
// Rollout stand-down: the next bundle already holds Cline credentials
// on this machine, so signing in from this legacy straggler window
// would create a competing token family next never adopts (its
// migration never overwrites an existing cline entry). Point the user
// at the window reload that switches them to the next bundle instead.
if (shouldStandDownAuth()) {
notifyRolloutStanddown()
return String.create({ value: "Reload this window to sign in on the new version of Cline" })
}
const callbackUrl = await HostProvider.get().getCallbackUrl("/auth")
const authUrl = await this._provider.getAuthRequest(callbackUrl)
@@ -320,13 +331,15 @@ export class AuthService {
Logger.warn("No user found after restoring auth token")
this._authenticated = false
this._clineAuthInfo = null
telemetryService.captureAuthLoggedOut(this._provider.name, LogoutReason.ERROR_RECOVERY)
// Fires on every window open for users with no stored session
// (e.g. API-key users) — not a logout, hence the dedicated reason.
telemetryService.captureAuthLoggedOut(this._provider.name, LogoutReason.NO_STORED_SESSION)
}
} catch (error) {
Logger.error("Error restoring auth token:", error)
this._authenticated = false
this._clineAuthInfo = null
telemetryService.captureAuthLoggedOut(this._provider.name, LogoutReason.ERROR_RECOVERY)
telemetryService.captureAuthLoggedOut(this._provider.name, LogoutReason.RESTORE_ERROR)
return
}
}
@@ -0,0 +1,126 @@
// Tests for the user.auth_logged_out reason mapping: the old catch-all
// ERROR_RECOVERY is split into NO_STORED_SESSION / TOKEN_INVALID / RESTORE_ERROR.
import { expect } from "chai"
import { afterEach, beforeEach, describe, it } from "mocha"
import * as sinon from "sinon"
import { Controller } from "@/core/controller"
import { resetTelemetryService } from "@/services/telemetry"
import { TelemetryService } from "@/services/telemetry/TelemetryService"
import { Logger } from "@/shared/services/Logger"
import { AuthInvalidTokenError, AuthNetworkError } from "../../error/ClineError"
import { AuthService, ClineAuthInfo } from "../AuthService"
import { LogoutReason } from "../types"
class TestableAuthService extends AuthService {
constructor(controller: Controller) {
super(controller)
}
}
/** The exported telemetryService proxy resolves the real service asynchronously. */
async function flushTelemetry(): Promise<void> {
await new Promise((resolve) => setImmediate(resolve))
await new Promise((resolve) => setImmediate(resolve))
}
describe("AuthService logout telemetry reasons", () => {
let sandbox: sinon.SinonSandbox
let capturedLoggedOut: sinon.SinonStub
let service: TestableAuthService
let retrieveClineAuthInfoStub: sinon.SinonStub
const authInfo: ClineAuthInfo = {
idToken: "id-token",
refreshToken: "refresh-token",
expiresAt: Date.now() / 1000 + 3600,
userInfo: {
createdAt: "2026-01-01T00:00:00Z",
displayName: "Test User",
email: "test@example.com",
id: "user_1",
organizations: [],
},
provider: "cline",
}
beforeEach(() => {
sandbox = sinon.createSandbox()
sandbox.stub(Logger, "warn")
sandbox.stub(Logger, "error")
sandbox.stub(Logger, "info")
sandbox.stub(Logger, "debug")
capturedLoggedOut = sandbox.stub(TelemetryService.prototype, "captureAuthLoggedOut")
sandbox.stub(TelemetryService, "create").resolves(new TelemetryService([], {} as any))
resetTelemetryService()
service = new TestableAuthService({} as Controller)
sandbox.stub(service, "sendAuthStatusUpdate").resolves()
retrieveClineAuthInfoStub = sandbox.stub((service as any)._provider, "retrieveClineAuthInfo")
})
afterEach(() => {
resetTelemetryService()
sandbox.restore()
})
describe("restoreRefreshTokenAndRetrieveAuthInfo()", () => {
it("reports no_stored_session when restore finds no session", async () => {
retrieveClineAuthInfoStub.resolves(null)
await service.restoreRefreshTokenAndRetrieveAuthInfo()
await flushTelemetry()
expect(capturedLoggedOut.firstCall.args).to.deep.equal(["cline", LogoutReason.NO_STORED_SESSION])
})
it("reports restore_error when restore throws", async () => {
retrieveClineAuthInfoStub.rejects(new Error("secret storage unavailable"))
await service.restoreRefreshTokenAndRetrieveAuthInfo()
await flushTelemetry()
expect(capturedLoggedOut.firstCall.args).to.deep.equal(["cline", LogoutReason.RESTORE_ERROR])
})
it("reports nothing when restore succeeds", async () => {
retrieveClineAuthInfoStub.resolves(authInfo)
await service.restoreRefreshTokenAndRetrieveAuthInfo()
await flushTelemetry()
expect(capturedLoggedOut.called).to.be.false
expect((service as any)._authenticated).to.be.true
})
})
describe("getAuthToken() refresh failures", () => {
beforeEach(() => {
// Signed-in session whose access token needs a refresh.
;(service as any)._clineAuthInfo = { ...authInfo, expiresAt: Date.now() / 1000 - 60 }
;(service as any)._authenticated = true
sandbox.stub((service as any)._provider, "shouldRefreshIdToken").resolves(true)
})
it("reports token_invalid when the refresh token is rejected", async () => {
retrieveClineAuthInfoStub.rejects(new AuthInvalidTokenError("invalid or expired token"))
const token = await service.getAuthToken()
await flushTelemetry()
expect(token).to.be.null
expect(capturedLoggedOut.firstCall.args).to.deep.equal(["cline", LogoutReason.TOKEN_INVALID])
})
it("reports nothing on transient network failures", async () => {
retrieveClineAuthInfoStub.rejects(new AuthNetworkError("status: 503"))
const token = await service.getAuthToken()
await flushTelemetry()
expect(token).to.be.null
expect(capturedLoggedOut.called).to.be.false
})
})
})
@@ -0,0 +1,146 @@
import * as assert from "node:assert"
import * as fs from "node:fs"
import * as os from "node:os"
import * as path from "node:path"
import {
decideStanddown,
nextBundleOwnsClineAccount,
resetRolloutStanddownForTests,
shouldStandDownAuth,
} from "../rollout-standdown"
const originalVariant = process.env.CLINE_ROLLOUT_VARIANT
const originalDataDir = process.env.CLINE_DATA_DIR
afterEach(() => {
resetRolloutStanddownForTests()
restoreEnv("CLINE_ROLLOUT_VARIANT", originalVariant)
restoreEnv("CLINE_DATA_DIR", originalDataDir)
})
describe("rollout auth stand-down", () => {
const base = {
envOverride: undefined,
settingOverride: undefined,
cached: undefined,
previousFailure: false,
nextOwnsClineAccount: true,
}
it("stands down only when the cached cohort assignment is next", () => {
assert.strictEqual(decideStanddown({ ...base, cached: "next" }), true)
assert.strictEqual(decideStanddown({ ...base, cached: "legacy" }), false)
assert.strictEqual(decideStanddown({ ...base, cached: undefined }), false)
assert.strictEqual(decideStanddown({ ...base, cached: "garbage" }), false)
})
it("never stands down until the next bundle holds the Cline credentials", () => {
// Single-window case: the cohort flag flipped mid-session, but next has
// never activated (or holds no cline refresh token) — this window is the
// sole owner of the token family and must keep working untouched.
assert.strictEqual(decideStanddown({ ...base, cached: "next", nextOwnsClineAccount: false }), false)
// Both conditions hold: straggler alongside an active next bundle.
assert.strictEqual(decideStanddown({ ...base, cached: "next", nextOwnsClineAccount: true }), true)
})
it("never stands down when the user forced a bundle (mirrors the loader's precedence)", () => {
// Forced legacy: the user deliberately keeps this window on legacy.
assert.strictEqual(decideStanddown({ ...base, cached: "next", envOverride: "legacy" }), false)
assert.strictEqual(decideStanddown({ ...base, cached: "next", settingOverride: "legacy" }), false)
// Forced next: the loader would never have activated this bundle; be safe anyway.
assert.strictEqual(decideStanddown({ ...base, cached: "next", envOverride: "next" }), false)
assert.strictEqual(decideStanddown({ ...base, cached: "next", settingOverride: "next" }), false)
// "auto" is not an override.
assert.strictEqual(decideStanddown({ ...base, cached: "next", settingOverride: "auto" }), true)
})
it("never stands down when the loader crash-pinned this VSIX version to legacy", () => {
assert.strictEqual(decideStanddown({ ...base, cached: "next", previousFailure: true }), false)
})
it("shouldStandDownAuth is inert outside combined rollout legacy builds", () => {
// Standalone/marketplace/dev builds have no CLINE_ROLLOUT_VARIANT: a
// leftover loader memento must never disable auth there.
delete process.env.CLINE_ROLLOUT_VARIANT
assert.strictEqual(shouldStandDownAuth(), false)
process.env.CLINE_ROLLOUT_VARIANT = "next"
assert.strictEqual(shouldStandDownAuth(), false)
})
it("shouldStandDownAuth is inert before initialization even in rollout legacy builds", () => {
process.env.CLINE_ROLLOUT_VARIANT = "legacy"
assert.strictEqual(shouldStandDownAuth(), false)
})
})
describe("nextBundleOwnsClineAccount", () => {
let tempDir: string
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "standdown-probe-"))
process.env.CLINE_DATA_DIR = tempDir
})
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true })
})
function writeProvidersFile(contents: string): void {
const dir = path.join(tempDir, "settings")
fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(path.join(dir, "providers.json"), contents)
}
it("is false when providers.json does not exist (next never activated)", () => {
assert.strictEqual(nextBundleOwnsClineAccount(), false)
})
it("is false when the cline entry has no auth (e.g. CLI picked a model but never signed in)", () => {
writeProvidersFile(
JSON.stringify({
version: 1,
providers: {
cline: {
settings: { provider: "cline", model: "anthropic/claude-sonnet-4.6" },
updatedAt: "2026-01-01T00:00:00Z",
tokenSource: "manual",
},
},
}),
)
assert.strictEqual(nextBundleOwnsClineAccount(), false)
})
it("is true when the cline entry holds a refresh token (migrated or signed in on next)", () => {
writeProvidersFile(
JSON.stringify({
version: 1,
providers: {
cline: {
settings: {
provider: "cline",
auth: { accessToken: "at", refreshToken: "rt-1", accountId: "user_1" },
},
updatedAt: "2026-01-01T00:00:00Z",
tokenSource: "migration",
},
},
}),
)
assert.strictEqual(nextBundleOwnsClineAccount(), true)
})
it("fails open on malformed json", () => {
writeProvidersFile("{not json")
assert.strictEqual(nextBundleOwnsClineAccount(), false)
})
})
function restoreEnv(key: "CLINE_ROLLOUT_VARIANT" | "CLINE_DATA_DIR", value: string | undefined): void {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
@@ -11,6 +11,7 @@ import { fetch, getAxiosSettings } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
import { type ClineAccountUserInfo, type ClineAuthInfo } from "../AuthService"
import { parseJwtPayload } from "../oca/utils/utils"
import { notifyRolloutStanddown, shouldStandDownAuth } from "../rollout-standdown"
interface ClineAuthApiUser {
subject: string | null
@@ -188,6 +189,23 @@ export class ClineAuthProvider {
}
if (await this.shouldRefreshIdToken(storedAuthData.refreshToken, storedAuthData.expiresAt)) {
// Rollout stand-down: this machine is assigned to the next
// bundle AND next already holds a copy of these credentials,
// so this legacy window must never rotate the shared refresh
// token again (a rotation from here would strand next with a
// consumed token). Keep using the current access token until
// it truly expires, then surface a "reload this window" notice
// instead of refreshing. The stored blob is deliberately left
// intact.
if (shouldStandDownAuth()) {
if (this.timeUntilExpiry(storedAuthData.idToken) > 30) {
return storedAuthData
}
Logger.debug("Rollout stand-down: suppressing token refresh in legacy straggler window")
notifyRolloutStanddown()
return null
}
// If the token hasn't expired yet,
// and it failed the first refresh attempt
// with something other than invalid token
@@ -0,0 +1,227 @@
import * as fs from "node:fs"
import * as os from "node:os"
import * as path from "node:path"
import * as vscode from "vscode"
import { HostProvider } from "@/hosts/host-provider"
import { getExtensionVariant } from "@/services/telemetry/rollout-metadata"
import { ShowMessageType } from "@/shared/proto/host/window"
import { Logger } from "@/shared/services/Logger"
// ---------------------------------------------------------------------------
// Rollout auth stand-down
//
// During the combined-VSIX A/B rollout, this (legacy) bundle and the next
// (SDK) bundle keep Cline-account credentials in different stores, and the
// refresh endpoint ROTATES the refresh token: whichever bundle refreshes
// last strands the other with a consumed token and a hard logout.
//
// That hazard needs TWO holders of the same token family. The second holder
// is born when the next bundle first activates on this machine and its
// credential migration copies the legacy blob into providers.json (or the
// user signs in on next directly). So a legacy window stands down only when
// BOTH are true:
//
// 1. the loader's cached cohort assignment says this machine's next window
// reload activates the next bundle, and
// 2. next demonstrably holds Cline-account credentials on this machine
// (providers.json has a cline entry with a refresh token).
//
// Until (2), the user may be sitting in their one and only window after the
// cohort flag flipped mid-session — that window is the SOLE owner of the
// token family, its rotations are harmless, and they keep the blob fresh for
// the eventual migration. It must keep working untouched, indefinitely.
//
// Once both hold, the straggler stands down: it never refreshes/rotates
// again, keeps working until its current access token naturally expires, and
// then presents a clear "reload this window to continue on the new version"
// message.
//
// Critically, standing down NEVER clears the stored credential blob — the
// next bundle's migration reads it, and a demotion back to legacy must find
// it intact.
// ---------------------------------------------------------------------------
/**
* Loader-owned state and settings, mirrored from apps/vscode-rollout on main
* (src/cohort.ts) keep in sync. The loader shares this extension's
* globalState, so the memento keys are directly readable here.
*/
export const COHORT_STATE_KEY = "cline.rollout.bundle"
export const FAILED_VERSION_STATE_KEY = "cline.rollout.nextActivationFailedVersion"
export const BUNDLE_OVERRIDE_ENV = "CLINE_BUNDLE_OVERRIDE"
export const SETTING_BUNDLE_OVERRIDE = "bundleOverride"
const NIGHTLY_EXTENSION_NAME = "cline-nightly"
export interface StanddownInputs {
/** CLINE_BUNDLE_OVERRIDE, if set. Beats everything (mirrors the loader). */
envOverride: string | undefined
/** The <prefix>.rollout.bundleOverride user setting ("auto" = no override). */
settingOverride: string | undefined
/** The loader's cached cohort assignment ("next" | "legacy" | undefined). */
cached: string | undefined
/** The next bundle failed to activate on this VSIX version (loader pinned legacy). */
previousFailure: boolean
/** The next bundle holds Cline-account credentials on this machine (see nextBundleOwnsClineAccount). */
nextOwnsClineAccount: boolean
}
function asBundle(value: unknown): "next" | "legacy" | undefined {
return value === "next" || value === "legacy" ? value : undefined
}
/**
* Whether a legacy window should stand down its Cline-account auth. Mirrors
* the loader's decideBundle() precedence, then additionally requires that the
* next bundle has actually taken co-ownership of the Cline account: "the next
* window reload on this machine activates next AND next already holds the
* credentials". Overrides and the crash pin keep this window fully
* functional the user (or the loader's safety net) explicitly chose legacy
* in those cases.
*/
export function decideStanddown(inputs: StanddownInputs): boolean {
const forced = asBundle(inputs.envOverride) ?? asBundle(inputs.settingOverride)
if (forced) {
return false // forced legacy keeps working; forced next never activates this bundle
}
if (inputs.previousFailure) {
return false
}
return inputs.cached === "next" && inputs.nextOwnsClineAccount
}
/**
* Where the next (SDK) bundle keeps its data. Mirrors the SDK's
* resolveDataDirFromEnv() on main (apps/vscode/src/shared/storage/
* storage-context.ts) keep in sync: CLINE_DATA_DIR > CLINE_DIR/data >
* ~/.cline/data.
*/
function resolveNextDataDir(): string {
const envDataDir = process.env.CLINE_DATA_DIR?.trim()
if (envDataDir) {
return envDataDir
}
const clineDir = process.env.CLINE_DIR?.trim() || path.join(os.homedir(), ".cline")
return path.join(clineDir, "data")
}
/**
* True when the next bundle demonstrably co-owns the Cline account on this
* machine: its providers.json has a cline entry holding a refresh token
* written either by its credential migration on first activation (which
* copies this legacy blob) or by a sign-in performed on next (including the
* CLI, which shares the same store and also rotates).
*
* A cline entry WITHOUT auth (e.g. the CLI configured a model but never
* signed in) does not count: whoever holds no refresh token cannot rotate,
* so this window rotating strands nobody.
*
* Deliberately fail-open: no file, unreadable, or malformed false, auth
* keeps working.
*/
export function nextBundleOwnsClineAccount(): boolean {
try {
const file = path.join(resolveNextDataDir(), "settings", "providers.json")
const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as {
providers?: Record<string, { settings?: { auth?: { refreshToken?: unknown } } }>
}
const refreshToken = parsed?.providers?.cline?.settings?.auth?.refreshToken
return typeof refreshToken === "string" && refreshToken.length > 0
} catch {
return false
}
}
let extensionContext: vscode.ExtensionContext | undefined
let reloadWindowCallback: (() => void) | undefined
let notified = false
/**
* Called once from activate() so the cohort memento can be read later.
* The window reload is injected as a callback because direct
* vscode.commands usage is restricted to extension.ts by the host-bridge
* lint rules (src/dev/grit/vscode-api.grit).
*/
export function initializeRolloutStanddown(context: vscode.ExtensionContext, reloadWindow?: () => void): void {
extensionContext = context
reloadWindowCallback = reloadWindow
}
function readInputs(context: vscode.ExtensionContext): StanddownInputs {
const packageJSON = (context.extension?.packageJSON ?? {}) as {
name?: string
version?: string
}
const prefix = packageJSON.name === NIGHTLY_EXTENSION_NAME ? "cline-nightly" : "cline"
return {
envOverride: process.env[BUNDLE_OVERRIDE_ENV],
settingOverride: vscode.workspace.getConfiguration(`${prefix}.rollout`).get<string>(SETTING_BUNDLE_OVERRIDE),
cached: context.globalState.get<string>(COHORT_STATE_KEY),
previousFailure:
packageJSON.version !== undefined &&
context.globalState.get<string>(FAILED_VERSION_STATE_KEY) === packageJSON.version,
nextOwnsClineAccount: nextBundleOwnsClineAccount(),
}
}
/**
* True when this window is a legacy straggler on a machine assigned to the
* next cohort AND the next bundle already holds the Cline credentials.
* Re-reads the loader's memento and providers.json on every call, so both a
* demotion (flag dialed back down) and next releasing the credentials
* re-enable auth without a reload and a next window activating alongside
* this one engages the stand-down without a reload.
*
* Only ever true for bundles built by the combined rollout workflow
* (CLINE_ROLLOUT_VARIANT="legacy") standalone/marketplace legacy builds and
* dev builds never stand down, even if a loader memento is present.
*/
export function shouldStandDownAuth(): boolean {
if (getExtensionVariant() !== "legacy") {
return false
}
const context = extensionContext
if (!context) {
return false
}
try {
return decideStanddown(readInputs(context))
} catch (error) {
Logger.error("[RolloutStanddown] Failed to evaluate cohort state:", error)
return false
}
}
/**
* One-time-per-window notice shown when auth actually stands down (token
* expired and refresh was suppressed, or the user tried to sign in here).
*/
export function notifyRolloutStanddown(): void {
if (notified) {
return
}
notified = true
const reload = "Reload Window"
HostProvider.window
.showMessage({
type: ShowMessageType.WARNING,
message:
"You've been signed out in this window because this machine was upgraded to the new version of Cline. " +
"Reload the window to keep using your account on the new version.",
options: { items: [reload] },
})
.then((response) => {
if (response.selectedOption === reload) {
reloadWindowCallback?.()
}
})
.catch((error) => {
Logger.error("[RolloutStanddown] Failed to show stand-down notice:", error)
})
}
/** Test-only: reset module state between tests. */
export function resetRolloutStanddownForTests(): void {
extensionContext = undefined
reloadWindowCallback = undefined
notified = false
}
+7 -1
View File
@@ -7,8 +7,14 @@ export enum LogoutReason {
USER_INITIATED = "user_initiated",
/** Auth tokens were cleared in another VSCode window (cross-window sync) */
CROSS_WINDOW_SYNC = "cross_window_sync",
/** Auth provider encountered an error and cleared tokens */
/** @deprecated No longer emitted — split into NO_STORED_SESSION / TOKEN_INVALID / RESTORE_ERROR */
ERROR_RECOVERY = "error_recovery",
/** Refresh token rejected as invalid/expired — a real involuntary logout */
TOKEN_INVALID = "token_invalid",
/** Activated with no stored Cline session (e.g. API-key users) — not a logout */
NO_STORED_SESSION = "no_stored_session",
/** Restoring the stored session on activation threw an error */
RESTORE_ERROR = "restore_error",
/** Unknown or unspecified reason */
UNKNOWN = "unknown",
}
@@ -10,6 +10,9 @@ export enum ClineErrorType {
QuotaExceeded = "quotaExceeded",
Entitlement = "entitlement",
OrgClinePassRestriction = "orgClinePassRestriction",
ClinePassLimit = "clinePassLimit",
ClineFreeModelLimit = "clineFreeModelLimit",
ClineFreePromotionEnded = "clineFreePromotionEnded",
}
interface ErrorDetails {
@@ -57,6 +60,102 @@ const ORG_CLINE_PASS_RESTRICTION_MESSAGE =
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.";
// The daily Cline free-model limit message is dynamic ("Daily free limit reached on
// model <id>. Try again in 23h 59m"), so it is matched by its fixed marker and the
// reset window is extracted from the trailing "try again in" clause.
const CLINE_FREE_MODEL_LIMIT_MARKER = "free limit reached on model";
const CLINE_FREE_MODEL_LIMIT_RETRY_MARKER = "try again in ";
// Once a free promotion ends, the cline-free/ model is removed from the catalog
// and the backend answers "model not found" to requests against it. The prefix
// gate keeps ordinary model-not-found errors on their generic path. Kept local
// (rather than importing shared/cline/free-models) so this file stays free of
// import cycles — it is loaded by both the extension host and the webview.
const CLINE_FREE_MODEL_ID_PREFIX = "cline-free/";
const CLINE_MODEL_NOT_FOUND_MARKER = "model not found";
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 function isClineFreeModelLimitMessage(text: string): boolean {
return text.toLowerCase().includes(CLINE_FREE_MODEL_LIMIT_MARKER);
}
export function isClineModelNotFoundMessage(text: string): boolean {
return text.toLowerCase().includes(CLINE_MODEL_NOT_FOUND_MARKER);
}
/**
* Detects a request against a retired free model: once a promotion ends the
* cline-free/ model is removed from the catalog and the backend answers "model
* not found". Mirrors the CLI's detection in apps/cli (cline-pass-errors.ts).
*/
export function isClineFreePromotionEndedMessage(
text: string,
modelId?: string,
): boolean {
if (!modelId?.toLowerCase().startsWith(CLINE_FREE_MODEL_ID_PREFIX)) {
return false;
}
return isClineModelNotFoundMessage(text);
}
/**
* Extracts the reset window ("23h 59m") out of a daily free-limit message so the UI
* can tell the user when the model becomes available again.
*/
export function extractClineFreeModelLimitResetTime(
text: string,
): string | undefined {
const message = text.toLowerCase();
const resetStart = message.indexOf(CLINE_FREE_MODEL_LIMIT_RETRY_MARKER);
if (resetStart === -1) {
return undefined;
}
const resetTime = message
.slice(resetStart + CLINE_FREE_MODEL_LIMIT_RETRY_MARKER.length)
.trim();
return resetTime || undefined;
}
export class ClineError extends Error {
readonly title = "ClineError";
readonly _error: ErrorDetails;
@@ -202,6 +301,43 @@ export class ClineError extends Error {
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;
// Daily Cline free-model limits are a different remedy than the ClinePass
// limit (pick another model / switch to the paid twin, not usage billing),
// so classify them first and keep them off the 429 rate-limit path.
if (
isClineFreeModelLimitMessage(detailMessage ?? "") ||
isClineFreeModelLimitMessage(message ?? "")
) {
return ClineErrorType.ClineFreeModelLimit;
}
if (
isClinePassLimitMessage(detailMessage ?? "") ||
isClinePassLimitMessage(message ?? "")
) {
return ClineErrorType.ClinePassLimit;
}
// Retired free models must be classified before the auth branch: the
// backend's model-not-found answer is a 404, which falls inside the
// generic 401-428 auth-status range below.
const promotionEndedModelId = err.modelId ?? err._error?.modelId;
if (
isClineFreePromotionEndedMessage(
detailMessage ?? "",
promotionEndedModelId,
) ||
isClineFreePromotionEndedMessage(message ?? "", promotionEndedModelId)
) {
return ClineErrorType.ClineFreePromotionEnded;
}
// Check auth errors
const isAuthStatus = status !== undefined && status > 400 && status < 429;
if (
@@ -1,6 +1,14 @@
import { describe, it } from "mocha";
import "should";
import { ClineError, ClineErrorType } from "../ClineError";
import {
ClineError,
ClineErrorType,
extractClineFreeModelLimitResetTime,
extractClinePassLimitMessage,
isClineFreeModelLimitMessage,
isClineFreePromotionEndedMessage,
isClinePassLimitMessage,
} from "../ClineError";
describe("ClineError", () => {
describe("getErrorType", () => {
@@ -59,7 +67,7 @@ describe("ClineError", () => {
"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);
@@ -89,5 +97,227 @@ describe("ClineError", () => {
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);
});
it("should classify daily Cline free model limits separately", () => {
const err = new ClineError(
"Error: Error 429: Daily free limit reached on model deepseek/deepseek-v4-flash. Try again in 23h 59m",
);
ClineError.getErrorType(err)!.should.equal(
ClineErrorType.ClineFreeModelLimit,
);
});
it("should classify nested daily Cline free model limits as ClineFreeModelLimit", () => {
// ClineError maps `error.error` into `details`, matching the real provider error shape.
const err = new ClineError({
message: "429 Error 429",
error: {
message:
"Daily free limit reached on model deepseek/deepseek-v4-flash. Try again in 5h 12m",
},
});
ClineError.getErrorType(err)!.should.equal(
ClineErrorType.ClineFreeModelLimit,
);
});
it("should prefer ClineFreeModelLimit over the generic rate-limit patterns", () => {
// The message carries "429", which the RATE_LIMIT_PATTERNS would otherwise match.
const err = new ClineError({
message:
"Error 429: Daily free limit reached on model deepseek/deepseek-v4-flash. Try again in 23h 59m",
status: 429,
});
const result = ClineError.getErrorType(err);
result!.should.equal(ClineErrorType.ClineFreeModelLimit);
(result !== ClineErrorType.RateLimit).should.be.true();
});
});
describe("isClineFreeModelLimitMessage", () => {
it("matches daily and generic free-limit messages", () => {
isClineFreeModelLimitMessage(
"Daily free limit reached on model deepseek/deepseek-v4-flash. Try again in 23h 59m",
).should.be.true();
isClineFreeModelLimitMessage(
"Free limit reached on model cline-free/glm-5",
).should.be.true();
});
it("does not match unrelated messages", () => {
isClineFreeModelLimitMessage(
"You have reached your weekly Clinepass limit. The limit resets in 7d, please try again later.",
).should.be.false();
isClineFreeModelLimitMessage("some other error").should.be.false();
});
});
describe("extractClineFreeModelLimitResetTime", () => {
it("extracts the reset window out of the limit message", () => {
extractClineFreeModelLimitResetTime(
"Daily free limit reached on model deepseek/deepseek-v4-flash. Try again in 23h 59m",
)!.should.equal("23h 59m");
});
it("returns undefined when there is no reset window", () => {
(
extractClineFreeModelLimitResetTime(
"Daily free limit reached on model deepseek/deepseek-v4-flash.",
) === undefined
).should.be.true();
});
});
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();
});
});
describe("isClineFreePromotionEndedMessage", () => {
it("matches model-not-found only for cline-free model ids", () => {
isClineFreePromotionEndedMessage(
"Error 404: Model not found",
"cline-free/glm-5",
).should.be.true();
isClineFreePromotionEndedMessage(
"Error 404: Model not found",
"deepseek/deepseek-v4-flash",
).should.be.false();
isClineFreePromotionEndedMessage(
"Error 404: Model not found",
undefined,
).should.be.false();
});
it("does not match unrelated cline-free errors", () => {
isClineFreePromotionEndedMessage(
"Network error: socket hang up",
"cline-free/glm-5",
).should.be.false();
});
});
describe("ClineFreePromotionEnded classification", () => {
it("classifies model-not-found for a cline-free model as ClineFreePromotionEnded", () => {
const err = new ClineError(
{ message: "Error 404: Model not found" },
"cline-free/glm-5",
"cline",
);
ClineError.getErrorType(err)!.should.equal(
ClineErrorType.ClineFreePromotionEnded,
);
});
it("prefers ClineFreePromotionEnded over Auth for a 404 with a cline-free model", () => {
// status 404 falls inside the generic auth-status range; the
// promotion-ended classification must win.
const err = new ClineError(
{ message: "Error 404: Model not found", status: 404 },
"cline-free/glm-5",
"cline",
);
const result = ClineError.getErrorType(err);
result!.should.equal(ClineErrorType.ClineFreePromotionEnded);
(result !== ClineErrorType.Auth).should.be.true();
});
it("classifies the nested provider error shape via the serialized modelId", () => {
// ErrorRow re-parses the serialized error in the webview; the model id
// rides the payload, not the message.
const original = new ClineError(
{
status: 404,
error: { message: "Model not found" },
},
"cline-free/glm-5",
"cline-pass",
);
const reparsed = ClineError.parse(original.serialize())!;
ClineError.getErrorType(reparsed)!.should.equal(
ClineErrorType.ClineFreePromotionEnded,
);
});
it("keeps model-not-found for a non-free model on the generic path", () => {
const err = new ClineError(
{ message: "Error 404: Model not found", status: 404 },
"deepseek/deepseek-v4-flash",
"cline",
);
const result = ClineError.getErrorType(err);
(result !== ClineErrorType.ClineFreePromotionEnded).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({
@@ -1364,6 +1399,7 @@ export class TelemetryService {
* @param requestId Unique identifier for the specific API request
* @param errorMessage Detailed error message from the API provider
* @param errorStatus HTTP status code of the error response, if available
* @param errorClass Cross-architecture failure class ("context_window_exceeded" | "unknown")
* @param collect Optional flag to determine if the event should be collected for batch sending
*/
public captureProviderApiError(args: {
@@ -1373,6 +1409,22 @@ export class TelemetryService {
provider?: string
errorStatus?: number | undefined
requestId?: string | undefined
// Mirror the SDK extension's provider-failure schema so the A/B rollout
// dashboards can compare cohorts on identical event shapes.
// errorType follows ClineErrorType values ("auth", "balance",
// "rateLimit", ...); failurePhase follows the SDK extension's
// vocabulary ("preflight" | "streaming"). Call sites only report
// failures that surfaced to the user (no auto-retry followed) — the
// SDK extension can only ever see those, so this keeps the cohorts
// comparable without any query-side filtering.
errorType?: string | undefined
failurePhase?: string | undefined
// Failure class shared verbatim with the SDK extension (which attaches
// the same property to this event), so one query counts a class across
// both rollout cohorts. Orthogonal to errorType: that taxonomy covers
// account/transport failures and drives retry control flow, so it is
// deliberately not extended with a context-window value.
errorClass?: "context_window_exceeded" | "unknown"
isNativeToolCall?: boolean
}) {
this.capture({
@@ -1384,22 +1436,61 @@ export class TelemetryService {
},
})
// Omit the key entirely when unclassified rather than passing
// undefined: the OTel provider stringifies undefined attribute values,
// which would bucket unclassified failures under a literal "undefined"
// instead of leaving the dimension absent.
const errorClassAttribute = args.errorClass ? { error_class: args.errorClass } : {}
this.recordCounter(TelemetryService.METRICS.ERRORS.TOTAL, 1, {
ulid: args.ulid,
model: args.model,
provider: args.provider,
error_status: args.errorStatus,
error_type: args.errorType,
failure_phase: args.failurePhase,
...errorClassAttribute,
})
const errorAttributes = {
ulid: args.ulid,
model: args.model,
provider: args.provider,
error_status: args.errorStatus,
error_type: args.errorType,
failure_phase: args.failurePhase,
...errorClassAttribute,
}
const errorCount = this.incrementTaskCounter(this.taskErrorCounts, args.ulid)
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)
@@ -287,8 +343,17 @@ describe("TelemetryService metrics", () => {
errorMessage: "boom",
provider: "anthropic",
errorStatus: 500,
errorType: "auth",
failurePhase: "streaming",
})
// The event carries the SDK-extension-parity schema fields so the A/B
// rollout dashboards can compare cohorts on identical shapes.
const failureEvent = provider.logs.find((entry) => entry.event === "task.provider_api_error")
assert.ok(failureEvent, "expected task.provider_api_error event")
assert.strictEqual(failureEvent?.properties?.errorType, "auth")
assert.strictEqual(failureEvent?.properties?.failurePhase, "streaming")
assert.strictEqual(provider.counters.length, 1)
const entry = provider.counters[0]
assert.strictEqual(entry.name, TelemetryService.METRICS.ERRORS.TOTAL)
@@ -297,6 +362,8 @@ describe("TelemetryService metrics", () => {
assert.strictEqual(entry.attributes.provider, "anthropic")
assert.strictEqual(entry.attributes.model, "claude")
assert.strictEqual(entry.attributes.error_status, 500)
assert.strictEqual(entry.attributes.error_type, "auth")
assert.strictEqual(entry.attributes.failure_phase, "streaming")
assert.strictEqual(provider.histograms.length, 1)
const errorHistogram = provider.histograms[0]
assert.strictEqual(errorHistogram.name, TelemetryService.METRICS.ERRORS.PER_TASK)
@@ -305,6 +372,53 @@ describe("TelemetryService metrics", () => {
assert.strictEqual(errorHistogram.attributes.provider, "anthropic")
assert.strictEqual(errorHistogram.attributes.model, "claude")
assert.strictEqual(errorHistogram.attributes.error_status, 500)
assert.strictEqual(errorHistogram.attributes.error_type, "auth")
assert.strictEqual(errorHistogram.attributes.failure_phase, "streaming")
})
it("captureProviderApiError carries errorClass on the event and metric attributes", () => {
const provider = new FakeProvider()
const service = createTelemetryService(provider)
service.captureProviderApiError({
ulid: "task-overflow",
model: "claude",
errorMessage: "prompt is too long: 213462 tokens > 200000 maximum",
provider: "anthropic",
errorType: undefined,
failurePhase: "streaming",
errorClass: "context_window_exceeded",
})
// errorClass uses the same property name and values as the SDK
// extension, so one query counts a class across both cohorts.
const failureEvent = provider.logs.find((entry) => entry.event === "task.provider_api_error")
assert.ok(failureEvent, "expected task.provider_api_error event")
assert.strictEqual(failureEvent?.properties?.errorClass, "context_window_exceeded")
assert.strictEqual(provider.counters[0].attributes.error_class, "context_window_exceeded")
assert.strictEqual(provider.histograms[0].attributes.error_class, "context_window_exceeded")
})
it("captureProviderApiError omits errorClass when the caller did not classify", () => {
const provider = new FakeProvider()
const service = createTelemetryService(provider)
service.captureProviderApiError({
ulid: "task-unclassified",
model: "claude",
errorMessage: "boom",
provider: "anthropic",
failurePhase: "streaming",
})
// The key must be absent, not present-and-undefined: the OTel provider
// stringifies undefined values, which would create a literal
// "undefined" bucket instead of an absent dimension.
const failureEvent = provider.logs.find((entry) => entry.event === "task.provider_api_error")
assert.ok(failureEvent, "expected task.provider_api_error event")
assert.strictEqual("errorClass" in (failureEvent?.properties ?? {}), false)
assert.strictEqual("error_class" in provider.counters[0].attributes, false)
assert.strictEqual("error_class" in provider.histograms[0].attributes, false)
})
it("captureTaskCompleted records completion payload with TTFT and duration histograms", () => {
@@ -0,0 +1,62 @@
import * as assert from "assert"
import {
getExtensionVariant,
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("exposes the variant for rollout builds only", () => {
process.env.CLINE_ROLLOUT_VARIANT = "legacy"
assert.strictEqual(getExtensionVariant(), "legacy")
process.env.CLINE_ROLLOUT_VARIANT = "next"
assert.strictEqual(getExtensionVariant(), "next")
delete process.env.CLINE_ROLLOUT_VARIANT
assert.strictEqual(getExtensionVariant(), undefined)
process.env.CLINE_ROLLOUT_VARIANT = "invalid"
assert.strictEqual(getExtensionVariant(), undefined)
})
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,47 @@
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
/**
* The rollout variant this bundle was built as, or undefined for ordinary builds.
* CLINE_ROLLOUT_VARIANT is inlined at build time by the combined rollout workflow only.
*/
export function getExtensionVariant(): ExtensionVariant | undefined {
const variant = process.env.CLINE_ROLLOUT_VARIANT
return variant === "legacy" || variant === "next" ? variant : undefined
}
/** Return rollout metadata only for bundles built by the combined rollout workflow. */
export function getRolloutTelemetryMetadata(): Partial<RolloutTelemetryMetadata> {
const variant = getExtensionVariant()
return variant ? { 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),
}
}
@@ -70,6 +70,11 @@ export interface ExtensionState {
lastCompletedCommandTs?: number
userInfo?: UserInfo
version: string
/**
* Which rollout bundle this build is ("legacy" or "next"). Only present for
* bundles built by the combined rollout workflow; undefined for ordinary builds.
*/
extensionVariant?: "legacy" | "next"
distinctId: string
globalClineRulesToggles: ClineRulesToggles
localClineRulesToggles: ClineRulesToggles
@@ -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)
}
})
})
@@ -0,0 +1,143 @@
import { expect } from "chai"
import type { ModelInfo } from "../api"
import {
findPaidClineModelId,
formatClineFreeModelName,
getClineFreeModelSlug,
isClineFreeModelId,
resolveClineFreeModelInfo,
zeroPricedModelInfo,
} from "../cline/free-models"
describe("Cline free models", () => {
describe("isClineFreeModelId", () => {
it("matches cline-free ids case-insensitively", () => {
expect(isClineFreeModelId("cline-free/deepseek-v4-flash")).to.equal(true)
expect(isClineFreeModelId("Cline-Free/GLM-5")).to.equal(true)
})
it("does not match paid or ClinePass ids", () => {
expect(isClineFreeModelId("deepseek/deepseek-v4-flash")).to.equal(false)
expect(isClineFreeModelId("cline-pass/glm-5.2")).to.equal(false)
expect(isClineFreeModelId(undefined)).to.equal(false)
})
})
describe("getClineFreeModelSlug", () => {
it("returns the slug after the cline-free prefix", () => {
expect(getClineFreeModelSlug("cline-free/deepseek-v4-flash")).to.equal("deepseek-v4-flash")
})
it("returns undefined for non-free ids and empty slugs", () => {
expect(getClineFreeModelSlug("deepseek/deepseek-v4-flash")).to.equal(undefined)
expect(getClineFreeModelSlug("cline-free/")).to.equal(undefined)
})
})
describe("formatClineFreeModelName", () => {
it("appends (free) to free model names", () => {
expect(formatClineFreeModelName("cline-free/glm-5", "GLM 5")).to.equal("GLM 5 (free)")
})
it("falls back to the model id when no name is given", () => {
expect(formatClineFreeModelName("cline-free/glm-5")).to.equal("cline-free/glm-5 (free)")
})
it("does not double up the (free) marker", () => {
expect(formatClineFreeModelName("cline-free/glm-5", "GLM 5 (free)")).to.equal("GLM 5 (free)")
})
it("leaves paid model names untouched", () => {
expect(formatClineFreeModelName("z-ai/glm-5", "GLM 5")).to.equal("GLM 5")
})
})
describe("zeroPricedModelInfo", () => {
it("zeroes every price while preserving capabilities", () => {
const info: ModelInfo = {
name: "GLM 5",
maxTokens: 8_192,
contextWindow: 128_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 1.5,
outputPrice: 3,
cacheReadsPrice: 0.5,
cacheWritesPrice: 2,
}
expect(zeroPricedModelInfo(info)).to.deep.equal({
...info,
inputPrice: 0,
outputPrice: 0,
cacheReadsPrice: 0,
cacheWritesPrice: 0,
})
})
})
describe("findPaidClineModelId", () => {
const clineModelIds = [
"cline-free/deepseek-v4-flash",
"deepseek/deepseek-v4-flash",
"z-ai/glm-5",
"anthropic/claude-sonnet-5",
]
it("finds the paid counterpart by model slug", () => {
expect(findPaidClineModelId("cline-free/deepseek-v4-flash", clineModelIds)).to.equal("deepseek/deepseek-v4-flash")
})
it("never returns another free model id", () => {
expect(findPaidClineModelId("cline-free/deepseek-v4-flash", ["cline-free/deepseek-v4-flash"])).to.equal(undefined)
})
it("returns undefined for non-free ids or when no counterpart exists", () => {
expect(findPaidClineModelId("deepseek/deepseek-v4-flash", clineModelIds)).to.equal(undefined)
expect(findPaidClineModelId("cline-free/unknown-model", clineModelIds)).to.equal(undefined)
expect(findPaidClineModelId(undefined, clineModelIds)).to.equal(undefined)
})
})
describe("resolveClineFreeModelInfo", () => {
// cline-free/ ids are never published in the models catalog, so capabilities
// have to come from the paid twin sharing the slug.
const clineModels: Record<string, ModelInfo> = {
"deepseek/deepseek-v4-flash": {
name: "DeepSeek V4 Flash",
maxTokens: 393_216,
contextWindow: 1_048_576,
supportsImages: false,
supportsPromptCache: true,
inputPrice: 1,
outputPrice: 2,
cacheReadsPrice: 0.1,
cacheWritesPrice: 0.2,
},
}
it("borrows the paid twin's capabilities and zeroes the pricing", () => {
const info = resolveClineFreeModelInfo("cline-free/deepseek-v4-flash", clineModels)
expect(info?.contextWindow).to.equal(1_048_576)
expect(info?.maxTokens).to.equal(393_216)
expect(info?.supportsPromptCache).to.equal(true)
expect(info?.inputPrice).to.equal(0)
expect(info?.outputPrice).to.equal(0)
expect(info?.cacheReadsPrice).to.equal(0)
expect(info?.cacheWritesPrice).to.equal(0)
expect(info?.name).to.equal("DeepSeek V4 Flash (free)")
})
it("returns undefined when there is no paid twin to borrow from", () => {
expect(resolveClineFreeModelInfo("cline-free/unknown-model", clineModels)).to.equal(undefined)
expect(resolveClineFreeModelInfo("cline-free/deepseek-v4-flash", {})).to.equal(undefined)
expect(resolveClineFreeModelInfo("cline-free/deepseek-v4-flash", undefined)).to.equal(undefined)
})
it("ignores non-free ids", () => {
expect(resolveClineFreeModelInfo("deepseek/deepseek-v4-flash", clineModels)).to.equal(undefined)
expect(resolveClineFreeModelInfo(undefined, clineModels)).to.equal(undefined)
})
})
})
+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,
+100
View File
@@ -0,0 +1,100 @@
import type { ModelInfo } from "@shared/api"
/**
* Cline free models are exposed by the recommended-models endpoint under a
* dedicated namespace (for example `cline-free/deepseek-v4-flash`). They hit the
* same Cline API as paid models but are always billed at $0, and they disappear
* once their promotion ends.
*/
export const CLINE_FREE_MODEL_ID_PREFIX = "cline-free/"
const CLINE_FREE_MODEL_NAME_SUFFIX = " (free)"
function normalizeModelId(modelId: string): string {
return modelId.trim().toLowerCase()
}
export function isClineFreeModelId(modelId: string | undefined): boolean {
return normalizeModelId(modelId ?? "").startsWith(CLINE_FREE_MODEL_ID_PREFIX)
}
/**
* Returns the model slug of a `cline-free/` id (the part after the prefix), or
* undefined when the id is not a Cline free model id.
*/
export function getClineFreeModelSlug(modelId: string | undefined): string | undefined {
if (!isClineFreeModelId(modelId)) {
return undefined
}
const modelSlug = (modelId as string).trim().slice(CLINE_FREE_MODEL_ID_PREFIX.length)
return modelSlug.length > 0 ? modelSlug : undefined
}
/**
* Free models are explicitly marked in the UI so users can tell them apart from
* their paid counterparts, which share the same underlying model slug.
*/
export function formatClineFreeModelName(modelId: string, name?: string): string {
const resolvedName = name?.trim() || modelId
if (!isClineFreeModelId(modelId) || resolvedName.toLowerCase().endsWith(CLINE_FREE_MODEL_NAME_SUFFIX)) {
return resolvedName
}
return `${resolvedName}${CLINE_FREE_MODEL_NAME_SUFFIX}`
}
/**
* Free models ride usage billing at $0, so never display or accumulate cost for them.
*/
export function zeroPricedModelInfo(info: ModelInfo): ModelInfo {
return {
...info,
inputPrice: 0,
outputPrice: 0,
cacheReadsPrice: 0,
cacheWritesPrice: 0,
}
}
export function resolveClineFreeModelInfo(
freeModelId: string | undefined,
clineModels: Record<string, ModelInfo> | undefined | null,
): ModelInfo | undefined {
if (!isClineFreeModelId(freeModelId) || !clineModels) {
return undefined
}
const paidModelId = findPaidClineModelId(freeModelId, Object.keys(clineModels))
const paidModelInfo = paidModelId ? clineModels[paidModelId] : undefined
if (!paidModelInfo) {
return undefined
}
return zeroPricedModelInfo({
...paidModelInfo,
name: formatClineFreeModelName(freeModelId as string, paidModelInfo.name),
})
}
/**
* Free model ids are `cline-free/<model-slug>`; their paid counterpart is the
* catalog model with the same slug under its lab prefix (for example
* `cline-free/deepseek-v4-flash` -> `deepseek/deepseek-v4-flash`).
*/
export function findPaidClineModelId(freeModelId: string | undefined, clineModelIds: string[]): string | undefined {
const modelSlug = getClineFreeModelSlug(freeModelId)
if (!modelSlug) {
return undefined
}
const normalizedModelSlug = normalizeModelId(modelSlug)
return clineModelIds.find((modelId) => {
if (isClineFreeModelId(modelId)) {
return false
}
const normalizedModelId = normalizeModelId(modelId)
return normalizedModelId === normalizedModelSlug || normalizedModelId.endsWith(`/${normalizedModelSlug}`)
})
}
+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,
@@ -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,4 +1,5 @@
import type { ApiProvider } from "@shared/api"
import { isClineFreeModelId } from "@shared/cline/free-models"
function normalizeModelId(modelId: string): string {
return modelId.trim().toLowerCase()
@@ -13,8 +14,9 @@ export function isClineFreeModelException(modelId: string): boolean {
/**
* Filters OpenRouter model IDs based on provider-specific rules.
* For Cline provider: excludes :free models (except known exception models)
* For OpenRouter/Vercel: excludes cline/ prefixed models
* For Cline provider: excludes :free models (except known exception models and
* explicit cline-free/ models)
* For OpenRouter/Vercel: excludes cline/ prefixed models (including cline-free/)
* @param modelIds Array of model IDs to filter
* @param provider The current API provider
* @param allowedFreeModelIds Optional list of Cline free model IDs to keep visible
@@ -33,6 +35,10 @@ export function filterOpenRouterModelIds(
if (allowedFreeIdSet.has(normalizedModelId)) {
return true
}
// Explicit Cline free models are always selectable on the Cline routes
if (isClineFreeModelId(normalizedModelId)) {
return true
}
if (isClineFreeModelException(normalizedModelId)) {
return true
}
@@ -42,5 +48,5 @@ export function filterOpenRouterModelIds(
}
// For OpenRouter and Vercel AI Gateway providers: exclude Cline-specific models
return modelIds.filter((id) => !id.startsWith("cline/"))
return modelIds.filter((id) => !id.startsWith("cline/") && !isClineFreeModelId(id))
}
@@ -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"],
},
+35 -2
View File
@@ -1,5 +1,5 @@
import type { ChildProcess } from "node:child_process"
import { mkdtempSync, type PathLike, type RmOptions, readdirSync, rmSync } from "node:fs"
import { existsSync, mkdtempSync, type PathLike, type RmOptions, readdirSync, rmSync } from "node:fs"
import * as os from "node:os"
import * as path from "node:path"
import { type ElectronApplication, expect, type Frame, type Page, test } from "@playwright/test"
@@ -61,6 +61,37 @@ export class E2ETestHelper {
return `${baseName}${projectSuffix}`
}
/**
* Resolves the real VS Code executable for `_electron.launch()`.
*
* `@vscode/test-electron` hardcodes the macOS entry point as
* `Visual Studio Code.app/Contents/MacOS/Electron`, but recent VS Code builds
* ship that binary as `Code` instead, so launching fails with ENOENT.
* See https://github.com/microsoft/vscode-test/issues/355.
*
* When the reported path is missing, fall back to the bundle's real executable
* name(s) so E2E works on both the old and new layouts.
*/
public static resolveVSCodeExecutablePath(executablePath: string): string {
if (existsSync(executablePath)) {
return executablePath
}
if (process.platform !== "darwin") {
return executablePath
}
const macOSDir = path.dirname(executablePath)
for (const candidate of ["Code", "Code - Insiders"]) {
const candidatePath = path.join(macOSDir, candidate)
if (existsSync(candidatePath)) {
return candidatePath
}
}
return executablePath
}
public static async waitUntil(predicate: () => boolean | Promise<boolean>, maxDelay = 10000): Promise<void> {
let delay = 10
const start = Date.now()
@@ -373,7 +404,9 @@ export const e2e = test
})
.extend<{ openVSCode: (workspacePath: string) => Promise<ElectronApplication> }>({
openVSCode: async ({ userDataDir, channel }, use, testInfo) => {
const executablePath = await downloadAndUnzipVSCode(channel, undefined, new SilentReporter())
const executablePath = E2ETestHelper.resolveVSCodeExecutablePath(
await downloadAndUnzipVSCode(channel, undefined, new SilentReporter()),
)
await use(async (workspacePath: string) => {
// Create isolated Cline data directory for this test
+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", () => {
+5 -3
View File
@@ -46,7 +46,7 @@ export function shouldSkipReasoningForModel(modelId?: string): boolean {
if (!modelId) {
return false
}
return modelId.includes("grok-4") || modelId.includes("devstral") || modelId.includes("glm")
return modelId.includes("grok-4") || modelId.includes("devstral")
}
export function isAnthropicModelId(modelId: string): modelId is AnthropicModelId {
@@ -115,7 +115,9 @@ export function isGPT51PlusModel(id: string): boolean {
modelId.includes("gpt-5.4") ||
modelId.includes("gpt-5-4") ||
modelId.includes("gpt-5.5") ||
modelId.includes("gpt-5-5")
modelId.includes("gpt-5-5") ||
modelId.includes("gpt-5.6") ||
modelId.includes("gpt-5-6")
)
}
@@ -154,7 +156,7 @@ export function isHermesModelFamily(id: string): boolean {
export function isNextGenOpenSourceModelFamily(id: string): boolean {
const modelId = normalize(id)
return ["kimi-k2"].some((substring) => modelId.includes(substring))
return ["kimi-k2", "kimi-k3"].some((substring) => modelId.includes(substring))
}
export function isDevstralModelFamily(id: string): boolean {
@@ -1,3 +1,4 @@
import { getClineFreeModelSlug, isClineFreeModelId } from "@shared/cline/free-models"
import { mentionRegex, mentionRegexGlobal } from "@shared/context-mentions"
import { StringRequest } from "@shared/proto/cline/common"
import { FileSearchRequest, FileSearchType, RelativePathsRequest } from "@shared/proto/cline/file"
@@ -1105,7 +1106,20 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}
switch (selectedProvider) {
case "cline":
return `${selectedProvider}:${selectedModelId}`
// cline-free/ ids already carry their own namespace, so don't double
// up the provider prefix in the badge
return isClineFreeModelId(selectedModelId)
? `cline:${getClineFreeModelSlug(selectedModelId)} (free)`
: `${selectedProvider}:${selectedModelId}`
case "cline-pass":
// Free models selected on ClinePass go through Cline usage billing,
// so label them the same way as the cline provider
if (selectedModelId.startsWith("cline-pass/")) {
return `${selectedProvider}:${selectedModelId.replace(/^cline-pass\//, "")}`
}
return isClineFreeModelId(selectedModelId)
? `cline:${getClineFreeModelSlug(selectedModelId)} (free)`
: `cline:${selectedModelId}`
case "openai":
return `openai-compat:${selectedModelId}`
case "vscode-lm":
@@ -0,0 +1,118 @@
import { openRouterDefaultModelInfo } from "@shared/api"
import { findPaidClineModelId } from "@shared/cline/free-models"
import type { Mode } from "@shared/storage/types"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { useMemo, useState } from "react"
import { getModeSpecificFields } from "@/components/settings/utils/providerUtils"
import { useApiConfigurationHandlers } from "@/components/settings/utils/useApiConfigurationHandlers"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { extractClineFreeModelLimitResetTime } from "../../../../src/services/error/ClineError"
interface ClineFreeModelLimitErrorProps {
message: string
}
const CLINE_PROVIDER_ID = "cline"
const ClineFreeModelLimitError = ({ message }: ClineFreeModelLimitErrorProps) => {
const { apiConfiguration, mode, clineModels } = useExtensionState()
const { handleModeFieldsChange } = useApiConfigurationHandlers()
const [isSwitching, setIsSwitching] = useState(false)
const [didSwitch, setDidSwitch] = useState(false)
const [switchError, setSwitchError] = useState<string | undefined>()
const resetTime = extractClineFreeModelLimitResetTime(message)
const currentMode: Mode = mode ?? "act"
const modeFields = getModeSpecificFields(apiConfiguration, currentMode)
// Free models are selectable on both the cline and cline-pass providers, so
// read the model id from whichever provider is currently selected.
const selectedFreeModelId =
modeFields.apiProvider === "cline-pass"
? modeFields.clinePassModelId
: modeFields.apiProvider === CLINE_PROVIDER_ID
? modeFields.clineModelId
: undefined
const paidModelId = useMemo(
() => findPaidClineModelId(selectedFreeModelId, Object.keys(clineModels ?? {})),
[selectedFreeModelId, clineModels],
)
const handleSwitchToPaidModel = async () => {
if (!paidModelId) {
return
}
setIsSwitching(true)
setSwitchError(undefined)
try {
const modelInfo = clineModels?.[paidModelId] ?? {
...openRouterDefaultModelInfo,
name: paidModelId,
}
await handleModeFieldsChange(
{
apiProvider: {
plan: "planModeApiProvider",
act: "actModeApiProvider",
},
clineModelId: {
plan: "planModeClineModelId",
act: "actModeClineModelId",
},
clineModelInfo: {
plan: "planModeClineModelInfo",
act: "actModeClineModelInfo",
},
},
{
apiProvider: CLINE_PROVIDER_ID,
clineModelId: paidModelId,
clineModelInfo: modelInfo,
},
currentMode,
)
setDidSwitch(true)
} catch (error) {
console.error("Failed to switch to the paid model:", error)
setSwitchError(`Failed to switch model. Select ${paidModelId} in API Configuration settings.`)
} finally {
setIsSwitching(false)
}
}
return (
<div
className="p-2 border-none rounded-md mb-2 bg-(--vscode-textBlockQuote-background)"
data-testid="cline-free-model-limit-error">
<div className="text-error mb-2">Daily free model limit reached</div>
<div className="text-(--vscode-descriptionForeground) text-xs wrap-anywhere">
You've reached today's free usage limit for this model.
</div>
<div className="text-(--vscode-descriptionForeground) text-xs mt-2">
{resetTime ? `Try again in ${resetTime}` : "Try again later"} or select another model.
</div>
{paidModelId && (
<>
<div className="text-(--vscode-descriptionForeground) text-xs mt-2 wrap-anywhere">
Or switch to the paid version of this model ({paidModelId}) with usage-based billing.
</div>
<VSCodeButton
appearance="primary"
className="w-full mt-3"
disabled={isSwitching || didSwitch}
onClick={handleSwitchToPaidModel}>
{isSwitching ? "Switching..." : didSwitch ? "Switched to Usage-Based billing" : "Switch to Usage-Based billing"}
</VSCodeButton>
{didSwitch && (
<div className="text-(--vscode-descriptionForeground) text-xs mt-2">
Retry the request after switching.
</div>
)}
{switchError && <div className="text-error text-xs mt-2">{switchError}</div>}
</>
)}
</div>
)
}
export default ClineFreeModelLimitError
@@ -0,0 +1,37 @@
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react";
import { useExtensionState } from "@/context/ExtensionStateContext";
// Shown when a request targets a retired cline-free/ model: once its free
// promotion ends the model is removed from the catalog and the backend answers
// "model not found". Mirrors the CLI's "Free model promotion ended" banner,
// with the CLI's "/model" hint replaced by a button into the model picker.
const ClineFreePromotionEndedError = () => {
const { navigateToSettingsModelPicker } = useExtensionState();
return (
<div
className="p-2 border-none rounded-md mb-2 bg-(--vscode-textBlockQuote-background)"
data-testid="cline-free-promotion-ended-error"
>
<div className="text-error mb-2">Free model promotion ended</div>
<div className="text-(--vscode-descriptionForeground) text-xs wrap-anywhere">
The free promotion for this model has ended and it is no longer
available.
</div>
<div className="text-(--vscode-descriptionForeground) text-xs mt-2">
Select another model to continue.
</div>
<VSCodeButton
appearance="primary"
className="w-full mt-3"
onClick={() =>
navigateToSettingsModelPicker({ targetSection: "api-config" })
}
>
Select a Model
</VSCodeButton>
</div>
);
};
export default ClineFreePromotionEndedError;
@@ -0,0 +1,69 @@
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react";
import { useState } from "react";
import { useApiConfigurationHandlers } from "@/components/settings/utils/useApiConfigurationHandlers";
interface ClinePassLimitErrorProps {
message: string;
}
const ClinePassLimitError = ({ message }: ClinePassLimitErrorProps) => {
const { handleFieldsChange } = useApiConfigurationHandlers();
const [isSwitching, setIsSwitching] = useState(false);
const [didSwitch, setDidSwitch] = useState(false);
const [error, setError] = useState<string | undefined>();
const handleSwitchToUsageBasedBilling = async () => {
setIsSwitching(true);
setError(undefined);
try {
await handleFieldsChange({
planModeApiProvider: "cline",
actModeApiProvider: "cline",
});
setDidSwitch(true);
} catch (error) {
console.error("Failed to switch to Cline usage-based billing:", error);
setError(
"Failed to switch provider. Select Cline Usage-Billing in API Configuration settings.",
);
} finally {
setIsSwitching(false);
}
};
return (
<div
className="p-2 border-none rounded-md mb-2 bg-(--vscode-textBlockQuote-background)"
data-testid="cline-pass-limit-error"
>
<div className="text-error mb-2">ClinePass limit reached</div>
<div className="text-(--vscode-descriptionForeground) text-xs wrap-anywhere">
{message}
</div>
<div className="text-(--vscode-descriptionForeground) text-xs mt-2">
Would you like to switch to Usage-Based billing and retry with the
Cline provider?
</div>
<VSCodeButton
appearance="primary"
className="w-full mt-3"
disabled={isSwitching || didSwitch}
onClick={handleSwitchToUsageBasedBilling}
>
{isSwitching
? "Switching..."
: didSwitch
? "Switched to Usage-Based billing"
: "Switch to Usage-Based billing"}
</VSCodeButton>
{didSwitch && (
<div className="text-(--vscode-descriptionForeground) text-xs mt-2">
Retry the request after switching.
</div>
)}
{error && <div className="text-error text-xs mt-2">{error}</div>}
</div>
);
};
export default ClinePassLimitError;
@@ -3,7 +3,9 @@ import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import React, { useEffect, useMemo, useState } from "react"
import VSCodeButtonLink from "@/components/common/VSCodeButtonLink"
import { useClineAuth } from "@/context/ClineAuthContext"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { AccountServiceClient, TaskServiceClient } from "@/services/grpc-client"
import { useApiConfigurationHandlers } from "../settings/utils/useApiConfigurationHandlers"
interface CreditLimitErrorProps {
currentBalance: number
@@ -26,7 +28,11 @@ const CreditLimitError: React.FC<CreditLimitErrorProps> = ({
totalSpent,
}) => {
const { activeOrganization } = useClineAuth()
const { mode, navigateToSettings } = useExtensionState()
const { handleModeFieldChange } = useApiConfigurationHandlers()
const [fullBuyCreditsUrl, setFullBuyCreditsUrl] = useState<string>("")
const [isSwitchingToClinePass, setIsSwitchingToClinePass] = useState(false)
const [didSwitchToClinePass, setDidSwitchToClinePass] = useState(false)
const dashboardUrl = useMemo(() => {
return buyCreditsUrl ?? (activeOrganization?.organizationId ? DEFAULT_BUY_CREDITS_URL.ORG : DEFAULT_BUY_CREDITS_URL.USER)
@@ -48,6 +54,19 @@ const CreditLimitError: React.FC<CreditLimitErrorProps> = ({
fetchCallbackUrl()
}, [dashboardUrl])
const handleSwitchToClinePass = async () => {
setIsSwitchingToClinePass(true)
try {
await handleModeFieldChange({ plan: "planModeApiProvider", act: "actModeApiProvider" }, "cline-pass", mode)
setDidSwitchToClinePass(true)
navigateToSettings("api-config")
} catch (error) {
console.error("Failed to switch to ClinePass:", error)
} finally {
setIsSwitchingToClinePass(false)
}
}
// We have to divide because the balance is stored in microcredits
return (
<div className="p-2 border-none rounded-md mb-2 bg-(--vscode-textBlockQuote-background)">
@@ -66,6 +85,24 @@ const CreditLimitError: React.FC<CreditLimitErrorProps> = ({
</div>
</div>
<div className="mb-2">
<div className="text-(--vscode-descriptionForeground) text-xs mb-2">
Trying to use ClinePass instead of credits?
</div>
<VSCodeButton
appearance="secondary"
className="w-full"
disabled={isSwitchingToClinePass || didSwitchToClinePass}
onClick={handleSwitchToClinePass}>
<span className="codicon codicon-arrow-swap mr-1.5" />
{isSwitchingToClinePass
? "Switching..."
: didSwitchToClinePass
? "Switched to ClinePass"
: "Switch to ClinePass"}
</VSCodeButton>
</div>
<VSCodeButtonLink className="w-full mb-2" href={fullBuyCreditsUrl}>
<span className="codicon codicon-credit-card mr-[6px] text-[14px]" />
Buy Credits
@@ -223,7 +223,7 @@ export const ClinePassEntitlementError: Story = {
message: "403 Error 403: the user is not subscribed to required model plan",
status: 403,
code: "ENTITLEMENT_ERROR",
modelId: "cline-pass/glm-5.1",
modelId: "cline-pass/glm-5.2",
providerId: "cline-pass",
details: {
code: "ENTITLEMENT_ERROR",
@@ -1,9 +1,30 @@
import type { ClineMessage } from "@shared/ExtensionMessage";
import { ApiProvider } from "@shared/proto/cline/models";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import ErrorRow from "./ErrorRow";
const mockSetUserOrganization = vi.hoisted(() => vi.fn());
const mockUpdateApiConfigurationProto = vi.hoisted(() => vi.fn());
const mockNavigateToSettingsModelPicker = vi.hoisted(() => vi.fn());
const mockApiConfiguration = vi.hoisted(() => ({
planModeApiProvider: "cline-pass",
actModeApiProvider: "cline-pass",
planModeClinePassModelId: "cline-pass/test-plan-model",
actModeClinePassModelId: "cline-pass/test-act-model",
}));
// The free-model limit card offers the paid twin of the selected free model, which
// it resolves out of the Cline catalog by model slug.
const mockClineModels = vi.hoisted(() => ({
"deepseek/deepseek-v4-flash": {
name: "DeepSeek V4 Flash",
maxTokens: 8_192,
contextWindow: 128_000,
supportsPromptCache: false,
inputPrice: 1,
outputPrice: 2,
},
}));
// Mock the auth context
vi.mock("@/context/ClineAuthContext", () => ({
@@ -30,10 +51,22 @@ vi.mock("@/components/chat/EntitlementError", () => ({
),
}));
vi.mock("@/context/ExtensionStateContext", () => ({
useExtensionState: () => ({
apiConfiguration: mockApiConfiguration,
mode: "act",
clineModels: mockClineModels,
navigateToSettingsModelPicker: mockNavigateToSettingsModelPicker,
}),
}));
vi.mock("@/services/grpc-client", () => ({
AccountServiceClient: {
setUserOrganization: mockSetUserOrganization,
},
ModelsServiceClient: {
updateApiConfigurationProto: mockUpdateApiConfigurationProto,
},
}));
// Mock ClineError
@@ -47,7 +80,19 @@ vi.mock("../../../../src/services/error/ClineError", () => ({
Auth: "auth",
Entitlement: "entitlement",
OrgClinePassRestriction: "orgClinePassRestriction",
ClinePassLimit: "clinePassLimit",
ClineFreeModelLimit: "clineFreeModelLimit",
ClineFreePromotionEnded: "clineFreePromotionEnded",
},
extractClinePassLimitMessage: vi.fn((text: string) => text),
extractClineFreeModelLimitResetTime: vi.fn((text: string) => {
const marker = "try again in ";
const start = text.toLowerCase().indexOf(marker);
if (start === -1) {
return undefined;
}
return text.slice(start + marker.length).trim() || undefined;
}),
}));
describe("ErrorRow", () => {
@@ -61,6 +106,7 @@ describe("ErrorRow", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSetUserOrganization.mockResolvedValue({});
mockUpdateApiConfigurationProto.mockResolvedValue({});
});
it("renders basic error message", () => {
@@ -266,6 +312,212 @@ describe("ErrorRow", () => {
).toBeInTheDocument();
});
it("renders ClinePass limit error and switches to Cline usage-based billing", async () => {
const limitMessage =
"You have reached your weekly Clinepass limit. The limit resets in 7d, please try again later.";
const mockClineError = {
message: limitMessage,
isErrorType: vi.fn((type) => type === "clinePassLimit"),
providerId: "cline-pass",
_error: {
message: limitMessage,
},
};
const { ClineError } = await import(
"../../../../src/services/error/ClineError"
);
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any);
render(
<ErrorRow
apiRequestFailedMessage={limitMessage}
errorType="error"
message={mockMessage}
/>,
);
expect(screen.getByTestId("cline-pass-limit-error")).toBeInTheDocument();
expect(screen.getByText(limitMessage)).toBeInTheDocument();
fireEvent.click(screen.getByText("Switch to Usage-Based billing"));
await waitFor(() =>
expect(mockUpdateApiConfigurationProto).toHaveBeenCalledTimes(1),
);
// The proto conversion maps provider id strings to ApiProvider enum values.
const request = mockUpdateApiConfigurationProto.mock.calls[0][0];
expect(request.apiConfiguration.planModeApiProvider).toBe(
ApiProvider.CLINE,
);
expect(request.apiConfiguration.actModeApiProvider).toBe(
ApiProvider.CLINE,
);
expect(
screen.getByText("Switched to Usage-Based billing"),
).toBeInTheDocument();
});
it("does not offer the usage-based billing switch when already on the cline provider", async () => {
const limitMessage =
"You have reached your weekly Clinepass limit. The limit resets in 7d, please try again later.";
const mockClineError = {
message: limitMessage,
isErrorType: vi.fn((type) => type === "clinePassLimit"),
providerId: "cline",
_error: {
message: limitMessage,
},
};
const { ClineError } = await import(
"../../../../src/services/error/ClineError"
);
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any);
render(
<ErrorRow
apiRequestFailedMessage={limitMessage}
errorType="error"
message={mockMessage}
/>,
);
expect(
screen.queryByTestId("cline-pass-limit-error"),
).not.toBeInTheDocument();
expect(screen.getByText(limitMessage)).toBeInTheDocument();
});
it("renders a daily free model limit without usage-billing guidance", async () => {
const limitMessage =
"Daily free limit reached on model deepseek/deepseek-v4-flash. Try again in 23h 59m";
const mockClineError = {
message: limitMessage,
isErrorType: vi.fn((type) => type === "clineFreeModelLimit"),
providerId: "cline",
_error: { message: limitMessage },
};
const { ClineError } = await import(
"../../../../src/services/error/ClineError"
);
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any);
render(
<ErrorRow
apiRequestFailedMessage={limitMessage}
errorType="error"
message={mockMessage}
/>,
);
expect(
screen.getByTestId("cline-free-model-limit-error"),
).toBeInTheDocument();
expect(
screen.getByText(
/You've reached today's free usage limit for this model/,
),
).toBeInTheDocument();
expect(screen.getByText(/Try again in 23h 59m/)).toBeInTheDocument();
// The raw backend message (including the model id) is replaced by the copy above
expect(screen.queryByText(limitMessage)).not.toBeInTheDocument();
expect(
screen.queryByText(/Switch to Usage-Based billing/i),
).not.toBeInTheDocument();
});
it("renders the promotion-ended card with a route into the model picker", async () => {
const rawMessage = "Error 404: Model not found";
const mockClineError = {
message: rawMessage,
isErrorType: vi.fn((type) => type === "clineFreePromotionEnded"),
providerId: "cline",
modelId: "cline-free/glm-5",
_error: { message: rawMessage },
};
const { ClineError } = await import(
"../../../../src/services/error/ClineError"
);
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any);
render(
<ErrorRow
apiRequestFailedMessage={rawMessage}
errorType="error"
message={mockMessage}
/>,
);
expect(
screen.getByTestId("cline-free-promotion-ended-error"),
).toBeInTheDocument();
expect(
screen.getByText("Free model promotion ended"),
).toBeInTheDocument();
expect(screen.getByText(/no longer\s+available/)).toBeInTheDocument();
// The raw backend message is replaced by the dedicated copy.
expect(screen.queryByText(rawMessage)).not.toBeInTheDocument();
fireEvent.click(screen.getByText("Select a Model"));
expect(mockNavigateToSettingsModelPicker).toHaveBeenCalledWith({
targetSection: "api-config",
});
});
it("offers the paid twin of the selected free model", async () => {
const limitMessage =
"Daily free limit reached on model cline-free/deepseek-v4-flash. Try again in 23h 59m";
const mockClineError = {
message: limitMessage,
isErrorType: vi.fn((type) => type === "clineFreeModelLimit"),
providerId: "cline",
_error: { message: limitMessage },
};
const { ClineError } = await import(
"../../../../src/services/error/ClineError"
);
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any);
// The configured provider drives which model id the card reads
mockApiConfiguration.actModeApiProvider = "cline";
(mockApiConfiguration as Record<string, unknown>).actModeClineModelId =
"cline-free/deepseek-v4-flash";
try {
render(
<ErrorRow
apiRequestFailedMessage={limitMessage}
errorType="error"
message={mockMessage}
/>,
);
expect(
screen.getByText(/deepseek\/deepseek-v4-flash/),
).toBeInTheDocument();
fireEvent.click(screen.getByText("Switch to Usage-Based billing"));
await waitFor(() =>
expect(mockUpdateApiConfigurationProto).toHaveBeenCalledTimes(1),
);
const request = mockUpdateApiConfigurationProto.mock.calls[0][0];
expect(request.apiConfiguration.actModeApiProvider).toBe(
ApiProvider.CLINE,
);
expect(request.apiConfiguration.actModeClineModelId).toBe(
"deepseek/deepseek-v4-flash",
);
} finally {
mockApiConfiguration.actModeApiProvider = "cline-pass";
delete (mockApiConfiguration as Record<string, unknown>)
.actModeClineModelId;
}
});
it("renders friendly logged-out message and sign in button when user is not signed in", async () => {
const mockClineError = {
message: "Authentication failed",
@@ -1,6 +1,9 @@
import type { ClineMessage } from "@shared/ExtensionMessage";
import { isClineProvider } from "@shared/utils/cline";
import { memo } from "react";
import ClineFreeModelLimitError from "@/components/chat/ClineFreeModelLimitError";
import ClineFreePromotionEndedError from "@/components/chat/ClineFreePromotionEndedError";
import ClinePassLimitError from "@/components/chat/ClinePassLimitError";
import CreditLimitError from "@/components/chat/CreditLimitError";
import EntitlementError from "@/components/chat/EntitlementError";
import OrgClinePassRestrictionError from "@/components/chat/OrgClinePassRestrictionError";
@@ -10,6 +13,7 @@ import { useClineAuth, useClineSignIn } from "@/context/ClineAuthContext";
import {
ClineError,
ClineErrorType,
extractClinePassLimitMessage,
} from "../../../../src/services/error/ClineError";
const _errorColor = "var(--vscode-errorForeground)";
@@ -90,6 +94,37 @@ const ErrorRow = memo(
return <OrgClinePassRestrictionError />;
}
// Gated on the ClinePass provider: users already on usage-based
// billing shouldn't be offered a switch to what they're on.
if (
clineError?.isErrorType(ClineErrorType.ClinePassLimit) &&
providerId === "cline-pass"
) {
const detailMessage =
clineError?._error?.details?.message || errorMessage;
const limitMessage =
extractClinePassLimitMessage(detailMessage) ?? detailMessage;
return <ClinePassLimitError message={limitMessage} />;
}
// Daily free-model limits have their own remedy (wait for the
// reset, pick another model, or switch to the paid twin), so
// they get dedicated copy instead of the raw backend message.
if (clineError?.isErrorType(ClineErrorType.ClineFreeModelLimit)) {
const detailMessage =
clineError?._error?.details?.message || errorMessage;
return <ClineFreeModelLimitError message={detailMessage} />;
}
// A retired free model answers model-not-found once its
// promotion ends — dedicated copy plus a route into the model
// picker, since retrying the deleted model can never succeed.
if (
clineError?.isErrorType(ClineErrorType.ClineFreePromotionEnded)
) {
return <ClineFreePromotionEndedError />;
}
if (clineError?.isErrorType(ClineErrorType.RateLimit)) {
return (
<p className="m-0 whitespace-pre-wrap text-error wrap-anywhere">
@@ -2,12 +2,13 @@ import { BANNER_DATA, BannerAction, BannerActionType, BannerCardData } from "@sh
import { EmptyRequest } from "@shared/proto/cline/common"
import type { Worktree } from "@shared/proto/cline/worktree"
import { TrackWorktreeViewOpenedRequest } from "@shared/proto/cline/worktree"
import { GitBranch } from "lucide-react"
import { GitBranch, Sparkles } from "lucide-react"
import React, { useCallback, useEffect, useMemo, useState } from "react"
import BannerCarousel from "@/components/common/BannerCarousel"
import BannerCarousel, { type BannerData } from "@/components/common/BannerCarousel"
import WhatsNewModal from "@/components/common/WhatsNewModal"
import HistoryPreview from "@/components/history/HistoryPreview"
import { useApiConfigurationHandlers } from "@/components/settings/utils/useApiConfigurationHandlers"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import HomeHeader from "@/components/welcome/HomeHeader"
import { SuggestedTasks } from "@/components/welcome/SuggestedTasks"
@@ -16,9 +17,12 @@ import { useClineAuth } from "@/context/ClineAuthContext"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { AccountServiceClient, StateServiceClient, UiServiceClient, WorktreeServiceClient } from "@/services/grpc-client"
import { convertBannerData } from "@/utils/bannerUtils"
import { buildClinePassSubscriptionUrl } from "@/utils/clinePassSubscription"
import { getCurrentPlatform } from "@/utils/platformUtils"
import { WelcomeSectionProps } from "../../types/chatTypes"
const CLINE_PASS_PROMO_BANNER_ID = "cline-pass-home-promo-v2"
/**
* Welcome section shown when there's no active task
* Includes info banner, announcements, home header, and history preview
@@ -68,6 +72,7 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
welcomeBanners,
} = useExtensionState()
const { handleFieldsChange } = useApiConfigurationHandlers()
const [dismissedLocalBanners, setDismissedLocalBanners] = useState<Set<string>>(() => new Set())
// Open modal once we have welcome banners
useEffect(() => {
@@ -160,7 +165,7 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
break
case BannerActionType.SetModel: {
const modelId = action.arg || "anthropic/claude-sonnet-4.5"
const modelId = action.arg || "anthropic/claude-sonnet-5"
const initialModelTab = action.tab || "recommended"
handleFieldsChange({
planModeOpenRouterModelId: modelId,
@@ -210,6 +215,8 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
* Dismissal handler - updates version tracking
*/
const handleBannerDismiss = useCallback((bannerId: string) => {
setDismissedLocalBanners((previous) => new Set(previous).add(bannerId))
// !! Do not continue use these version numbers or add new banners that don't have unique IDs. !!
// Banner versions are **deprecated**. Going forward, we are tracking which banners have
// been dismissed using the **banner ID**.
@@ -225,6 +232,65 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
}
}, [])
const clinePassPromoBanner = useMemo((): BannerData | undefined => {
if (
isBannerDismissed(CLINE_PASS_PROMO_BANNER_ID) ||
dismissedLocalBanners.has(CLINE_PASS_PROMO_BANNER_ID)
) {
return undefined
}
const dismissPromoBanner = () => handleBannerDismiss(CLINE_PASS_PROMO_BANNER_ID)
const subscriptionUrl = buildClinePassSubscriptionUrl(clineUser?.appBaseUrl)
return {
id: CLINE_PASS_PROMO_BANNER_ID,
icon: <Sparkles className="size-4 text-[var(--vscode-charts-yellow)]" />,
title: "Try ClinePass",
description: (
<div className="flex flex-col gap-2">
<p className="m-0">
A $9.99/month subscription for the latest open-weights models, at much lower cost than
paying for direct API access.
</p>
<div>
<Button
onClick={() => {
UiServiceClient.openUrl({ value: subscriptionUrl }).catch(console.error)
}}
size="sm">
Get ClinePass
</Button>
</div>
<button
className="w-fit cursor-pointer border-0 bg-transparent p-0 text-left text-xs text-[var(--vscode-textLink-foreground)] underline hover:cursor-pointer hover:text-[var(--vscode-textLink-activeForeground,var(--vscode-textLink-foreground))]"
onClick={async () => {
try {
await handleFieldsChange({
planModeApiProvider: "cline-pass",
actModeApiProvider: "cline-pass",
})
navigateToSettings("api-config")
} catch (error) {
console.error("Failed to switch to ClinePass:", error)
}
}}
type="button">
Switch to ClinePass provider to access subscription.
</button>
</div>
),
onDismiss: dismissPromoBanner,
}
}, [
clineUser?.appBaseUrl,
dismissedLocalBanners,
handleBannerDismiss,
handleFieldsChange,
isBannerDismissed,
navigateToSettings,
])
/**
* Build array of active banners for carousel
* Combines hardcoded banners (bannerConfig) with dynamic banners from extension state
@@ -247,8 +313,9 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
)
// Combine both sources: extension state banners first, then hardcoded banners
return [...extensionStateBanners, ...hardcodedBanners]
}, [bannerConfig, banners, clineUser, handleBannerAction, handleBannerDismiss])
const carouselBanners = [...extensionStateBanners, ...hardcodedBanners]
return clinePassPromoBanner ? [clinePassPromoBanner, ...carouselBanners] : carouselBanners
}, [bannerConfig, banners, clinePassPromoBanner, handleBannerAction, handleBannerDismiss])
return (
<div className="flex flex-col flex-1 w-full h-full p-0 m-0">
@@ -260,10 +327,10 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
welcomeBanners={welcomeBanners}
/>
<div className="overflow-y-auto flex flex-col pb-2.5">
<HomeHeader shouldShowQuickWins={shouldShowQuickWins} />
{!showWhatsNewModal && (
<>
<BannerCarousel banners={activeBanners} />
<HomeHeader shouldShowQuickWins={shouldShowQuickWins} />
{!shouldShowQuickWins && taskHistory.length > 0 && <HistoryPreview showHistoryView={showHistoryView} />}
{/* Quick launch worktree button */}
{isGitRepo && worktreesEnabled?.featureFlag && worktreesEnabled?.user && (
@@ -313,6 +380,7 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
)}
</>
)}
{showWhatsNewModal && <HomeHeader shouldShowQuickWins={shouldShowQuickWins} />}
</div>
<SuggestedTasks shouldShowQuickWins={shouldShowQuickWins} />
@@ -49,17 +49,22 @@ const BannerCardContent: React.FC<BannerCardContentProps> = ({ banner, isActive,
}}>
{/* Title with optional icon */}
<h3
className={cn("font-semibold mb-2 flex items-center gap-2 text-base pr-0", {
className={cn("font-semibold mb-2 flex items-center text-base pr-0", {
"gap-2": banner.icon,
"pr-6": showDismissButton,
})}>
<span className="shrink-0">{banner.icon}</span>
{banner.icon && <span className="shrink-0">{banner.icon}</span>}
{banner.title}
</h3>
{/* Description */}
<div className="text-sm text-description leading-relaxed [&>*:last-child]:mb-0 [&_a]:hover:underline">
{markdownContent}
</div>
{typeof banner.description === "string" ? (
<div className="text-sm text-description leading-relaxed [&>*:last-child]:mb-0 [&_a]:hover:underline">
{markdownContent}
</div>
) : (
<div className="text-sm text-description leading-relaxed">{banner.description}</div>
)}
{/* Action buttons */}
{banner.actions?.length ? (
@@ -1,5 +1,7 @@
import { buildModelInfoNameMap, type ModelInfo, resolveClinePassModelInfo } from "@shared/api"
import { StringRequest } from "@shared/proto/cline/common"
import type { OnboardingModel, OnboardingModelGroup, OpenRouterModelInfo } from "@shared/proto/index.cline"
import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { AlertCircleIcon, CircleCheckIcon, CircleIcon, ListIcon, LoaderCircleIcon, ZapIcon } from "lucide-react"
import { useCallback, useEffect, useMemo, useState } from "react"
import ClineLogoWhite from "@/assets/ClineLogoWhite"
@@ -7,15 +9,13 @@ import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Item, ItemContent, ItemDescription, ItemHeader, ItemMedia, ItemTitle } from "@/components/ui/item"
import { CLINE_PASS_FEATURE_FLAG } from "@/constants/featureFlags"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useHasFeatureFlag } from "@/hooks/useFeatureFlag"
import { cn } from "@/lib/utils"
import { AccountServiceClient, StateServiceClient } from "@/services/grpc-client"
import { setPendingClinePassSubscribe } from "./clinePassSubscribe"
import { AccountServiceClient, StateServiceClient, UiServiceClient } from "@/services/grpc-client"
import ApiConfigurationSection from "../settings/sections/ApiConfigurationSection"
import { useApiConfigurationHandlers } from "../settings/utils/useApiConfigurationHandlers"
import WelcomeView from "../welcome/WelcomeView"
import { setPendingClinePassSubscribe } from "./clinePassSubscribe"
import {
getCapabilities,
getClineUIOnboardingGroups,
@@ -235,7 +235,25 @@ const UserTypeSelectionStep = ({ userType, onSelectUserType, userTypeSelections
</ItemMedia>
<ItemContent className="w-full">
<ItemTitle>{option.title}</ItemTitle>
<ItemDescription>{option.description}</ItemDescription>
<ItemDescription>
{option.description}
{option.learnMoreUrl && (
<>
{" "}
<VSCodeLink
className="inline"
style={{ fontSize: "inherit" }}
onClick={(e) => {
e.stopPropagation()
UiServiceClient.openUrl(
StringRequest.create({ value: option.learnMoreUrl }),
).catch((err) => console.error("Failed to open learn more link:", err))
}}>
Learn more
</VSCodeLink>
</>
)}
</ItemDescription>
</ItemContent>
</Item>
)
@@ -301,8 +319,7 @@ const OnboardingStepContent = ({
const OnboardingViewContent = ({ onboardingModels }: { onboardingModels: OnboardingModelGroup }) => {
const { handleFieldsChange } = useApiConfigurationHandlers()
const { openRouterModels, hideSettings, hideAccount, setShowWelcome } = useExtensionState()
const isClinePassEnabled = useHasFeatureFlag(CLINE_PASS_FEATURE_FLAG)
const userTypeSelections = useMemo(() => getUserTypeSelections(isClinePassEnabled), [isClinePassEnabled])
const userTypeSelections = useMemo(() => getUserTypeSelections(true), [])
const [stepNumber, setStepNumber] = useState(0)
const [isActionLoading, setIsActionLoading] = useState(false)
@@ -312,7 +329,7 @@ const OnboardingViewContent = ({ onboardingModels }: { onboardingModels: Onboard
const [searchTerm, setSearchTerm] = useState("")
const models = useMemo(() => getClineUIOnboardingGroups(onboardingModels), [onboardingModels])
// ClinePass model IDs (e.g. "cline-pass/glm-5.1") aren't keyed in openRouterModels,
// ClinePass model IDs (e.g. "cline-pass/glm-5.2") aren't keyed in openRouterModels,
// so resolve their info via the slug-based lookup used by ClinePassProvider.
const openRouterModelsByName = useMemo(() => buildModelInfoNameMap(openRouterModels), [openRouterModels])
@@ -22,7 +22,7 @@ describe("getClineUIOnboardingGroups", () => {
it("buckets ClinePass models into the clinePass group", () => {
const result = getClineUIOnboardingGroups(
groupOf([
model("cline-pass/glm-5.1", CLINEPASS_GROUP),
model("cline-pass/glm-5.2", CLINEPASS_GROUP),
model("free-model", "free"),
model("anthropic/claude", "frontier"),
model("z-ai/glm", "open source"),
@@ -31,7 +31,7 @@ describe("getClineUIOnboardingGroups", () => {
expect(result.clinePass).toHaveLength(1)
expect(result.clinePass[0].group).toBe(CLINEPASS_GROUP)
expect(result.clinePass[0].models.map((m) => m.id)).toEqual(["cline-pass/glm-5.1"])
expect(result.clinePass[0].models.map((m) => m.id)).toEqual(["cline-pass/glm-5.2"])
expect(result.free[0].models.map((m) => m.id)).toEqual(["free-model"])
expect(result.power.flatMap((g) => g.models.map((m) => m.id))).toEqual(["anthropic/claude", "z-ai/glm"])
})
@@ -54,7 +54,7 @@ describe("getRecommendedModelsData", () => {
{
recommended: [],
free: [],
clinePass: [{ id: "cline-pass/glm-5.1", name: "GLM 5.1", description: "", tags: [] }],
clinePass: [{ id: "cline-pass/glm-5.2", name: "GLM 5.2", description: "", tags: [] }],
},
false,
)
@@ -67,12 +67,12 @@ describe("getRecommendedModelsData", () => {
{
recommended: [],
free: [],
clinePass: [{ id: "cline-pass/glm-5.1", name: "GLM 5.1", description: "", tags: [] }],
clinePass: [{ id: "cline-pass/glm-5.2", name: "GLM 5.2", description: "", tags: [] }],
},
true,
)
expect(result?.clinePass.map((model) => model.id)).toEqual(["cline-pass/glm-5.1"])
expect(result?.clinePass.map((model) => model.id)).toEqual(["cline-pass/glm-5.2"])
})
it("keeps classic recommended/free responses when the ClinePass feature flag is disabled", () => {
@@ -80,7 +80,7 @@ describe("getRecommendedModelsData", () => {
{
recommended: [{ id: "anthropic/claude", name: "Claude", description: "", tags: [] }],
free: [{ id: "free-model", name: "Free", description: "", tags: [] }],
clinePass: [{ id: "cline-pass/glm-5.1", name: "GLM 5.1", description: "", tags: [] }],
clinePass: [{ id: "cline-pass/glm-5.2", name: "GLM 5.2", description: "", tags: [] }],
},
false,
)
@@ -9,6 +9,7 @@ type UserTypeSelection = {
title: string
description: string
type: NEW_USER_TYPE
learnMoreUrl?: string
}
export const STEP_CONFIG = {
@@ -56,9 +57,10 @@ export const STEP_CONFIG = {
} as const
const CLINE_PASS_USER_TYPE_SELECTION: UserTypeSelection = {
title: "ClinePass (Recommended)",
description: "One subscription, curated models, no API keys",
title: "ClinePass",
description: "Low cost subscription plan for best open weights model.",
type: NEW_USER_TYPE.CLINE_PASS,
learnMoreUrl: "https://docs.cline.bot/getting-started/clinepass",
}
const BASE_USER_TYPE_SELECTIONS: UserTypeSelection[] = [
@@ -69,10 +71,9 @@ const BASE_USER_TYPE_SELECTIONS: UserTypeSelection[] = [
/**
* Returns the onboarding user-type options. The free option leads the list and is
* the default selection; ClinePass is inserted as a recommended-but-optional
* choice (labeled "Recommended") right after it, only when the `ext-cline-pass`
* feature flag is enabled. When the flag is off, the classic Free / Frontier /
* BYOK options are shown unchanged.
* the default selection; ClinePass is inserted right after it, only when the
* `ext-cline-pass` feature flag is enabled. When the flag is off, the classic
* Free / Frontier / BYOK options are shown unchanged.
*/
export function getUserTypeSelections(isClinePassEnabled: boolean): UserTypeSelection[] {
if (!isClinePassEnabled) {
@@ -4,9 +4,7 @@ import { EmptyRequest } from "@shared/proto/cline/common"
import type { ClineRecommendedModel } from "@shared/proto/cline/models"
import type { OnboardingModel, OnboardingModelGroup } from "@shared/proto/cline/state"
import { useEffect, useMemo, useState } from "react"
import { CLINE_PASS_FEATURE_FLAG } from "@/constants/featureFlags"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useHasFeatureFlag } from "@/hooks/useFeatureFlag"
import { ModelsServiceClient } from "@/services/grpc-client"
import { CLINEPASS_GROUP, getRecommendedModelsData, type RecommendedModelsData } from "./data-models"
@@ -51,7 +49,6 @@ type FetchState = { status: "loading" } | { status: "success"; data: Recommended
export function useOnboardingModels(): UseOnboardingModelsResult {
const { openRouterModels, clineModels, refreshClineModels } = useExtensionState()
const isClinePassEnabled = useHasFeatureFlag(CLINE_PASS_FEATURE_FLAG)
const [fetchState, setFetchState] = useState<FetchState>({ status: "loading" })
useEffect(() => {
@@ -61,7 +58,7 @@ export function useOnboardingModels(): UseOnboardingModelsResult {
try {
const response = await ModelsServiceClient.refreshClineRecommendedModelsRpc(EmptyRequest.create({}))
if (!cancelled) {
const data = getRecommendedModelsData(response, isClinePassEnabled)
const data = getRecommendedModelsData(response, true)
if (!data) {
setFetchState({ status: "empty" })
} else {
@@ -80,7 +77,7 @@ export function useOnboardingModels(): UseOnboardingModelsResult {
return () => {
cancelled = true
}
}, [isClinePassEnabled])
}, [])
useEffect(() => {
refreshClineModels()
@@ -91,7 +88,7 @@ export function useOnboardingModels(): UseOnboardingModelsResult {
return { ...openRouterModels, ...(clineModels ?? {}) }
}, [openRouterModels, clineModels])
// ClinePass model IDs omit the upstream lab (e.g. "cline-pass/glm-5.1"), so look up
// ClinePass model IDs omit the upstream lab (e.g. "cline-pass/glm-5.2"), so look up
// capabilities via the model slug against the OpenRouter catalog, falling back to
// conservative ClinePass defaults. Mirrors ClinePassProvider's resolution.
const openRouterModelsByName = useMemo(() => buildModelInfoNameMap(openRouterModels), [openRouterModels])
@@ -9,9 +9,7 @@ import styled from "styled-components"
import { normalizeApiConfiguration } from "@/components/settings/utils/providerUtils"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { PLATFORM_CONFIG, PlatformType } from "@/config/platform.config"
import { CLINE_PASS_FEATURE_FLAG } from "@/constants/featureFlags"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useHasFeatureFlag } from "@/hooks/useFeatureFlag"
import { ModelsServiceClient } from "@/services/grpc-client"
import { OPENROUTER_MODEL_PICKER_Z_INDEX } from "./OpenRouterModelPicker"
import { AIhubmixProvider } from "./providers/AihubmixProvider"
@@ -21,7 +19,6 @@ import { BasetenProvider } from "./providers/BasetenProvider"
import { BedrockProvider } from "./providers/BedrockProvider"
import { CerebrasProvider } from "./providers/CerebrasProvider"
import { ClaudeCodeProvider } from "./providers/ClaudeCodeProvider"
import { ClinePassProvider } from "./providers/ClinePassProvider"
import { ClineProvider } from "./providers/ClineProvider"
import { DeepSeekProvider } from "./providers/DeepSeekProvider"
import { DifyProvider } from "./providers/DifyProvider"
@@ -102,9 +99,8 @@ const ApiOptions = ({
}: ApiOptionsProps) => {
// Use full context state for immediate save payload
const { apiConfiguration, remoteConfigSettings } = useExtensionState()
const isClinePassEnabled = useHasFeatureFlag(CLINE_PASS_FEATURE_FLAG)
const { selectedProvider } = normalizeApiConfiguration(apiConfiguration, currentMode, { isClinePassEnabled })
const { selectedProvider } = normalizeApiConfiguration(apiConfiguration, currentMode, { isClinePassEnabled: true })
const { handleModeFieldChange } = useApiConfigurationHandlers()
@@ -145,9 +141,6 @@ const ApiOptions = ({
const providerOptions = useMemo(() => {
let providers = PROVIDERS.list
if (!isClinePassEnabled) {
providers = providers.filter((option) => option.value !== "cline-pass")
}
// Filter by platform
if (PLATFORM_CONFIG.type !== PlatformType.VSCODE) {
// Don't include VS Code LM API for non-VSCode platforms
@@ -157,15 +150,24 @@ const ApiOptions = ({
// Filter by remote config if remoteConfiguredProviders is set
const remoteProviders: string[] = remoteConfigSettings?.remoteConfiguredProviders || []
if (remoteProviders.length > 0) {
providers = providers.filter((option) => remoteProviders.includes(option.value))
const effectiveRemoteProviders =
remoteProviders.includes("cline-pass") && !remoteProviders.includes("cline")
? [...remoteProviders, "cline"]
: remoteProviders
providers = providers.filter((option) => effectiveRemoteProviders.includes(option.value))
}
return providers
}, [isClinePassEnabled, remoteConfigSettings])
}, [remoteConfigSettings])
const getProviderDisplayLabel = useCallback((option: (typeof PROVIDERS.list)[number]) => {
return option.value === "cline" ? "Cline Usage-Billing" : option.label
}, [])
const currentProviderLabel = useMemo(() => {
return providerOptions.find((option) => option.value === selectedProvider)?.label || selectedProvider
}, [providerOptions, selectedProvider])
const selectedOption = providerOptions.find((option) => option.value === selectedProvider)
return selectedOption ? getProviderDisplayLabel(selectedOption) : selectedProvider
}, [getProviderDisplayLabel, providerOptions, selectedProvider])
// Sync search term with current provider when not searching
useEffect(() => {
@@ -177,13 +179,19 @@ const ApiOptions = ({
const searchableItems = useMemo(() => {
return providerOptions.map((option) => ({
value: option.value,
html: option.label,
html: getProviderDisplayLabel(option),
searchText:
option.value === "cline"
? "Cline Usage Billing usage based pay as you go"
: option.value === "cline-pass"
? "ClinePass subscription included models"
: option.label,
}))
}, [providerOptions])
}, [getProviderDisplayLabel, providerOptions])
const fuse = useMemo(() => {
return new Fuse(searchableItems, {
keys: ["html"],
keys: ["html", "searchText"],
threshold: 0.3,
shouldSort: true,
isCaseSensitive: false,
@@ -366,20 +374,17 @@ const ApiOptions = ({
<HicapProvider currentMode={currentMode} isPopup={isPopup} showModelOptions={showModelOptions} />
)}
{apiConfiguration && selectedProvider === "cline" && (
{apiConfiguration && (selectedProvider === "cline" || selectedProvider === "cline-pass") && (
<ClineProvider
currentMode={currentMode}
initialModelTab={initialModelTab}
isClinePassEnabled={isClinePassEnabled}
isClinePassEnabled={true}
isPopup={isPopup}
selectedProvider={selectedProvider}
showModelOptions={showModelOptions}
/>
)}
{apiConfiguration && isClinePassEnabled && selectedProvider === "cline-pass" && (
<ClinePassProvider currentMode={currentMode} isPopup={isPopup} showModelOptions={showModelOptions} />
)}
{apiConfiguration && selectedProvider === "asksage" && (
<AskSageProvider currentMode={currentMode} isPopup={isPopup} showModelOptions={showModelOptions} />
)}
@@ -29,7 +29,7 @@ export const ClineAccountInfoCard = () => {
<div className="max-w-[600px]">
{user ? (
<VSCodeButton appearance="secondary" onClick={handleShowAccount}>
View Billing & Usage
View Billing History
</VSCodeButton>
) : (
<div>
@@ -37,7 +37,7 @@ export const ClineAccountInfoCard = () => {
Sign Up with Cline
{isLoading && (
<span className="ml-1 animate-spin">
<span className="codicon codicon-refresh"></span>
<span className="codicon codicon-refresh" />
</span>
)}
</VSCodeButton>
@@ -1,4 +1,10 @@
import { type ApiConfiguration, buildModelInfoNameMap, CLAUDE_SONNET_1M_SUFFIX, type ModelInfo } from "@shared/api"
import {
formatClineFreeModelName,
isClineFreeModelId,
resolveClineFreeModelInfo,
zeroPricedModelInfo,
} from "@shared/cline/free-models"
import { CLINE_RECOMMENDED_MODELS_FALLBACK } from "@shared/cline/recommended-models"
import { EmptyRequest, StringRequest } from "@shared/proto/cline/common"
import { type ClineRecommendedModel, ClineRecommendedModelsResponse } from "@shared/proto/cline/models"
@@ -47,6 +53,13 @@ const StarIcon = ({ isFavorite, onClick }: { isFavorite: boolean; onClick: (e: R
)
}
export interface FeaturedModelTab {
label: string
models: FeaturedModelCardEntry[]
// Optional explanatory copy shown between the tab bar and the model cards
description?: string
}
export interface ClineModelPickerProps {
isPopup?: boolean
currentMode: Mode
@@ -58,12 +71,17 @@ export interface ClineModelPickerProps {
models?: Record<string, ModelInfo>
isClinePassEnabled?: boolean
showFeaturedModels?: boolean
// Custom featured tabs (e.g. ClinePass "Subscribed"/"Free") shown instead of the
// built-in Recommended/Free tabs
featuredTabs?: FeaturedModelTab[]
}
interface FeaturedModelCardEntry {
export interface FeaturedModelCardEntry {
id: string
description: string
label: string
// Shown on the card instead of the id (e.g. ClinePass ids without their prefix)
displayName?: string
}
const CLINE_RECOMMENDED_MODELS_RETRY_DELAY_MS = 5000
@@ -72,7 +90,7 @@ function normalizeModelId(modelId: string): string {
return modelId.trim().toLowerCase()
}
function toFeaturedModelCardEntry(
export function toFeaturedModelCardEntry(
model: Pick<ClineRecommendedModel, "id" | "description" | "tags">,
fallbackLabel: string,
): FeaturedModelCardEntry | null {
@@ -109,6 +127,7 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({
models,
isClinePassEnabled = true,
showFeaturedModels = true,
featuredTabs,
}) => {
const { handleModeFieldsChange, handleFieldChange } = useApiConfigurationHandlers()
const { apiConfiguration, favoritedModelIds, clineModels, openRouterModels, refreshClineModels } = useExtensionState()
@@ -150,6 +169,7 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({
[freeClineModelIds],
)
const [activeTab, setActiveTab] = useState<"recommended" | "free">(initialTab ?? "recommended")
const [activeFeaturedTabIndex, setActiveFeaturedTabIndex] = useState(0)
const recommendedModels = useMemo(
() => (clineRecommendedModels.length > 0 ? clineRecommendedModels : RECOMMENDED_MODELS_FALLBACK),
[clineRecommendedModels],
@@ -230,13 +250,36 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({
const currentModelId = resolveModelId(configuredModelId)
setActiveTab(freeClineModelIdSet.has(normalizeModelId(currentModelId)) ? "free" : "recommended")
}, [configuredModelId, freeClineModelIdSet, initialTab, resolveModelId])
// Keep the active custom tab on the tab containing the configured model
useEffect(() => {
if (!featuredTabs) {
return
}
const currentModelId = resolveModelId(configuredModelId)
const tabIndex = featuredTabs.findIndex((tab) => tab.models.some((model) => model.id === currentModelId))
if (tabIndex >= 0) {
setActiveFeaturedTabIndex(tabIndex)
}
}, [configuredModelId, featuredTabs, resolveModelId])
const dropdownRef = useRef<HTMLDivElement>(null)
const itemRefs = useRef<(HTMLDivElement | null)[]>([])
const dropdownListRef = useRef<HTMLDivElement>(null)
const handleModelChange = (newModelId: string) => {
const handleModelChange = (rawModelId: string) => {
// When a fixed models map is provided (ClinePass), only ids in the map may be
// stored — otherwise the host would send arbitrary typed text to the API.
const newModelId = models && !(rawModelId in models) ? resolveModelId(rawModelId) : rawModelId
setSearchTerm(newModelId)
const newModelInfo = resolvedModels?.[newModelId]
// cline-free/ ids are not published in the models catalog, so borrow the paid
// twin's capabilities. Without this the handler would fall back to
// openRouterDefaultModelInfo and use Claude Sonnet's limits for another model.
const freeModelInfo = isClineFreeModelId(newModelId)
? (newModelInfo ?? resolveClineFreeModelInfo(newModelId, resolvedModels))
: undefined
handleModeFieldsChange(
{
clineModelId: modelIdFieldPair,
@@ -244,7 +287,14 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({
},
{
clineModelId: newModelId,
clineModelInfo: resolvedModels?.[newModelId],
// Free models ride usage billing at $0, so persist them zero-priced and
// with the explicit "(free)" name so cost UI never shows paid rates.
clineModelInfo: freeModelInfo
? zeroPricedModelInfo({
...freeModelInfo,
name: formatClineFreeModelName(newModelId, freeModelInfo.name),
})
: newModelInfo,
},
currentMode,
)
@@ -260,16 +310,22 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({
selectedModelInfo: resolvedModels?.[resolvedModelId] ?? normalizedSelection.selectedModelInfo,
}
: normalizedSelection
if (freeClineModelIdSet.has(normalizeModelId(selected.selectedModelId))) {
// Explicit cline-free/ ids are free even before the recommended-models
// response lands, so check the namespace as well as the fetched free list.
if (isClineFreeModelId(selected.selectedModelId) || freeClineModelIdSet.has(normalizeModelId(selected.selectedModelId))) {
// cline-free/ ids are absent from the catalog, so normalizeApiConfiguration
// can only supply the default info. Prefer the paid twin's real capabilities
// so the info panel and context-window UI don't show Claude Sonnet's limits.
const freeModelInfo =
resolvedModels?.[selected.selectedModelId] ??
resolveClineFreeModelInfo(selected.selectedModelId, resolvedModels) ??
selected.selectedModelInfo
return {
...selected,
selectedModelInfo: {
...selected.selectedModelInfo,
inputPrice: 0,
outputPrice: 0,
cacheReadsPrice: 0,
cacheWritesPrice: 0,
},
selectedModelInfo: zeroPricedModelInfo({
...freeModelInfo,
name: formatClineFreeModelName(selected.selectedModelId, freeModelInfo?.name),
}),
}
}
return selected
@@ -414,6 +470,8 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({
Object.entries(resolvedModels ?? {})?.some(([id, m]) => id === selectedModelId && m.thinkingConfig) ||
selectedModelIdLower.includes("claude-haiku-4.5") ||
selectedModelIdLower.includes("claude-4.5-haiku") ||
selectedModelIdLower.includes("claude-sonnet-5") ||
selectedModelIdLower.includes("claude-5-sonnet") ||
selectedModelIdLower.includes("claude-sonnet-4.6") ||
selectedModelIdLower.includes("claude-sonnet-4-6") ||
selectedModelIdLower.includes("claude-4.6-sonnet") ||
@@ -442,7 +500,52 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({
<span style={{ fontWeight: 500 }}>Model</span>
</label>
{showFeaturedModels && (
{showFeaturedModels && featuredTabs && (
<>
{/* Custom Tabs (e.g. ClinePass Subscribed/Free) */}
<TabsContainer style={{ marginTop: 4 }}>
{featuredTabs.map((tab, index) => (
<Tab
active={activeFeaturedTabIndex === index}
key={tab.label}
onClick={() => setActiveFeaturedTabIndex(index)}>
{tab.label}
</Tab>
))}
</TabsContainer>
{/* Tab Description */}
{featuredTabs[activeFeaturedTabIndex]?.description && (
<p
style={{
fontSize: "11px",
margin: "4px 0 6px 0",
color: "var(--vscode-descriptionForeground)",
}}>
{featuredTabs[activeFeaturedTabIndex].description}
</p>
)}
{/* Model Cards */}
<div style={{ marginBottom: "6px" }}>
{featuredTabs[activeFeaturedTabIndex]?.models.map((model) => (
<FeaturedModelCard
description={model.description}
isSelected={selectedModelId === model.id}
key={model.id}
label={model.label}
modelId={model.displayName ?? model.id}
onClick={() => {
handleModelChange(model.id)
setIsDropdownVisible(false)
}}
/>
))}
</div>
</>
)}
{showFeaturedModels && !featuredTabs && (
<>
{/* Tabs */}
<TabsContainer style={{ marginTop: 4 }}>
@@ -570,6 +673,22 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({
selectedModelId={selectedModelId}
/>
{/* Context window switcher for Claude Sonnet 5 */}
<ContextWindowSwitcher
base1mModelId={`anthropic/claude-sonnet-5${CLAUDE_SONNET_1M_SUFFIX}`}
base200kModelId="anthropic/claude-sonnet-5"
onModelChange={handleModelChange}
selectedModelId={selectedModelId}
/>
{/* Context window switcher for Claude Opus 5 */}
<ContextWindowSwitcher
base1mModelId={`anthropic/claude-opus-5${CLAUDE_SONNET_1M_SUFFIX}`}
base200kModelId="anthropic/claude-opus-5"
onModelChange={handleModelChange}
selectedModelId={selectedModelId}
/>
{/* Context window switcher for Claude Opus 4.8 */}
<ContextWindowSwitcher
base1mModelId={`anthropic/claude-opus-4.8${CLAUDE_SONNET_1M_SUFFIX}`}
@@ -654,8 +773,7 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({
marginTop: 0,
color: "var(--vscode-descriptionForeground)",
}}>
The extension automatically fetches the latest Cline model list. If you're unsure which model to choose, Cline
works best with <strong>anthropic/claude-sonnet-4.5</strong>.
The extension automatically fetches the latest Cline model list.
</p>
)}
</div>
@@ -221,6 +221,8 @@ const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({ isPopup,
Object.entries(openRouterModels)?.some(([id, m]) => id === selectedModelId && m.thinkingConfig) ||
selectedModelIdLower.includes("claude-haiku-4.5") ||
selectedModelIdLower.includes("claude-4.5-haiku") ||
selectedModelIdLower.includes("claude-sonnet-5") ||
selectedModelIdLower.includes("claude-5-sonnet") ||
selectedModelIdLower.includes("claude-sonnet-4.6") ||
selectedModelIdLower.includes("claude-sonnet-4-6") ||
selectedModelIdLower.includes("claude-4.6-sonnet") ||
@@ -331,6 +333,22 @@ const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({ isPopup,
selectedModelId={selectedModelId}
/>
{/* Context window switcher for Claude Sonnet 5 */}
<ContextWindowSwitcher
base1mModelId={`anthropic/claude-sonnet-5${CLAUDE_SONNET_1M_SUFFIX}`}
base200kModelId="anthropic/claude-sonnet-5"
onModelChange={handleModelChange}
selectedModelId={selectedModelId}
/>
{/* Context window switcher for Claude Opus 5 */}
<ContextWindowSwitcher
base1mModelId={`anthropic/claude-opus-5${CLAUDE_SONNET_1M_SUFFIX}`}
base200kModelId="anthropic/claude-opus-5"
onModelChange={handleModelChange}
selectedModelId={selectedModelId}
/>
{/* Context window switcher for Claude Opus 4.8 */}
<ContextWindowSwitcher
base1mModelId={`anthropic/claude-opus-4.8${CLAUDE_SONNET_1M_SUFFIX}`}
@@ -421,9 +439,9 @@ const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({ isPopup,
</VSCodeLink>
If you're unsure which model to choose, Cline works best with{" "}
<VSCodeLink
onClick={() => handleModelChange("anthropic/claude-sonnet-4.6")}
onClick={() => handleModelChange("anthropic/claude-sonnet-5")}
style={{ display: "inline", fontSize: "inherit" }}>
anthropic/claude-sonnet-4.6.
anthropic/claude-sonnet-5.
</VSCodeLink>
You can also try searching "free" for no-cost options currently available.
</p>
@@ -164,7 +164,8 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
[],
); // Empty deps - these imports never change
const { version, environment, settingsInitialModelTab } = useExtensionState();
const { version, extensionVariant, environment, settingsInitialModelTab } =
useExtensionState();
const { activeOrganization, clineUser } = useClineAuth();
const [activeTab, setActiveTab] = useState<string>(
@@ -278,6 +279,7 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
props.onResetState = handleResetState;
} else if (activeTab === "about") {
props.version = version;
props.extensionVariant = extensionVariant;
} else if (activeTab === "api-config") {
props.initialModelTab = settingsInitialModelTab;
}
@@ -288,6 +290,7 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
handleResetState,
settingsInitialModelTab,
version,
extensionVariant,
TAB_CONTENT_MAP,
]);
@@ -179,6 +179,8 @@ const VercelModelPicker: React.FC<VercelModelPickerProps> = ({ isPopup, currentM
return (
selectedModelIdLower.includes("claude-haiku-4.5") ||
selectedModelIdLower.includes("claude-4.5-haiku") ||
selectedModelIdLower.includes("claude-sonnet-5") ||
selectedModelIdLower.includes("claude-5-sonnet") ||
selectedModelIdLower.includes("claude-sonnet-4.6") ||
selectedModelIdLower.includes("claude-sonnet-4-6") ||
selectedModelIdLower.includes("claude-4.6-sonnet") ||

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