Compare commits

...

9 Commits

Author SHA1 Message Date
Saoud Rizwan 1ea34be611 chore(cli): release v3.0.42 2026-07-15 20:03:31 -07:00
Saoud Rizwan e72bc3cd14 chore(sdk): release v0.0.62 2026-07-15 19:46:58 -07:00
Tomás Barreiro 9c907af826 Send the Feature Flag Event when rolling out (#12325)
* Send the Feature Flag Event when rolling out

* Update apps/vscode-rollout/scripts/smoke-loader.mjs

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-15 19:39:37 -07:00
Saoud Rizwan e8d3d82522 fix(core): omit telemetry from hub tool contexts (#12326) 2026-07-15 19:33:12 -07:00
Max 7f9d2e96d9 fix(ollama): restore native API routing so context window and timeout settings work (#12286)
The 4.0.0 SDK migration routed Ollama through the generic OpenAI-compatible
vendor (/v1/chat/completions), which cannot express Ollama's options.num_ctx.
Every model loaded at Ollama's 4096-token server default, truncating Cline's
prompt and breaking most features (CLINE-2603, CLINE-2566, CLINE-2572).

- Add a native Ollama vendor backed by ai-sdk-ollama (wraps the official
  ollama client); num_ctx derives from the resolved gateway model's
  contextWindow at the adapter boundary, defaulting to 32768
- Persist the Model Context Window setting in providers.json via the
  pre-existing provider-neutral contextWindow field (legacy
  ollamaApiOptionsCtxNum state key kept as read fallback / write mirror),
  and surface it as the selected model's contextWindow so the chat
  indicator, compaction budgets, and num_ctx all agree
- Project ProviderConfig.maxInputTokens (where ProviderSettings.contextWindow
  lands) onto the selected gateway model in both gateway builders so
  CLI/Core hosts honor the configured value too
- Stop falling back to the bundled Ollama-Cloud catalog when /api/tags is
  empty; local-model-source providers keep the user's committed model
  instead of silently selecting a cloud model (nemotron)
- Wire Request Timeout (ms) with the legacy semantics (response must start
  within requestTimeoutMs || 30000; streaming never cut off mid-generation)
- Settings UI: gate the context-window field until provider config loads,
  skip unchanged writes, drop the custom prompt checkbox

Fixes CLINE-2603, CLINE-2566, CLINE-2572

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-15 17:55:29 -07:00
Saoud Rizwan d618f8073a fix(vscode-rollout): align bundle versions and harden combined publish workflows (#12321)
* fix(vscode-rollout): align bundle versions in the stable AB workflow

Found by Max in local testing: the union manifest's version (what the
Marketplace and auto-update see) is the stitch input, but each bundle's
About tab and telemetry extension_version read that bundle's OWN
package.json — so the stable combined VSIX reported three different
versions (dispatch input / main's 4.0.0 / legacy's 4.0.8) depending on
where you looked. The nightly channel doesn't have this problem
(nightlify.mjs stamps one version into everything); this gives the stable
channel the identity-preserving equivalent: scripts/set-version.mjs stamps
the dispatch version into each checkout after install, before its build.

Also fixes a latent ab-package bug while restructuring the steps: the
next-bundle build never ran build:sdk, so the @cline/* workspace deps had
no dist and esbuild would fail on a fresh CI checkout (the workflow has
never run end-to-end — the publish environment gate blocked pre-merge
dispatches). Split install/build:sdk/align/build into separate steps,
mirroring the nightly workflow.

* fix(vscode-rollout): assert bundle sub-manifest versions in identity guardrails

Greptile round on #12321: the stable guardrail didn't assert version at
all. Went one further than the suggestion — both workflows' guardrails now
also assert each bundle sub-manifest's version (and name, for nightly)
matches the expected version, which is the check that actually regression-
guards the set-version.mjs/nightlify.mjs stamping (About tab + telemetry
extension_version read the sub-manifests, not the union). Expected version
routed through env rather than interpolated into the script body. Adds the
conventional paired test for set-version.mjs.

* fix(vscode-rollout): don't fail the nightly run when the tag push is rejected

First real combined publish (run 29454994164) published to both registries
successfully but the run went red at the last step: the default
GITHUB_TOKEN cannot create a ref whose commit modifies workflow files, and
HEAD was the #12253 squash merge which rewrote this very workflow. There
is no workflows permission grantable to the token, so this recurs any
night HEAD touched .github/workflows. The tag is bookkeeping — mark the
step continue-on-error so a successful publish isn't reported as a
failure. (Today's missing tag was pushed manually.)
2026-07-15 17:39:44 -07:00
Saoud Rizwan eb21ba583c fix(ci): restrict nightly publishing to main (#12322) 2026-07-15 15:35:09 -07:00
Saoud Rizwan f29c25395c feat(vscode-rollout): A/B loader and packaging for staged SDK extension rollout (#12253)
* feat(vscode-rollout): A/B loader and packaging for staged SDK extension rollout

Ship one marketplace VSIX containing a tiny loader plus two complete
extension bundles: next/ (SDK-based apps/vscode from main) and legacy/
(the legacy-extension branch). The loader picks one bundle per window
from a PostHog-flag-driven, sticky, one-way cohort assignment, activates
it with a Proxy-scoped ExtensionContext so each bundle resolves its
resources from its own subdirectory, and falls back to legacy (with
partial-registration cleanup and version pinning) if the next bundle
crashes during activation.

Includes the union-manifest generator with per-cohort when-clause
gating, the VSIX stitcher, a node-level loader smoke test, and the
ext-vscode-ab-package workflow that builds both refs and packages
(optionally publishes) the combined VSIX.

* fix(vscode-rollout): address rollout review feedback

* feat(vscode-rollout): versioned kill-switch, user-setting override, launch-cadence telemetry

Review follow-ups from #12253:

- Kill-switch is now scoped by version instead of boolean: the PostHog flag's
  payload carries {"maxKilledVersion": "x.y.z"} and the loader demotes only
  combined VSIXes <= that version, so killing a broken release never blocks
  the release that fixes it. Arming with no payload still demotes everything,
  and the old boolean memento format is normalized on read.

- cline.rollout.bundleOverride user setting (auto | next | legacy) as a
  manual escape hatch editable straight from settings.json: beats flags and
  the kill-switch in both directions, applies on window reload, reported as
  'override' on the activation event. Injected into the union manifest by
  gen-manifest so neither bundle has to know about it.

- parseRolloutFlags hardens flag typing: only a literal boolean true promotes
  (multivariate variants, numbers, junk fail safe), kill payloads are parsed
  defensively from /decide's JSON-string encoding.

- Activation events now carry ms_since_last_activation so the real window-
  reload cadence bounds how fast the rollout percentage gets dialed up.

- Walkthrough manifest invariant relaxed from byte-equality to structural
  equality (ids/media/completionEvents): the branches already diverge on one
  MCP step description, and since walkthrough markdown at the VSIX root comes
  from next regardless, hard-failing on copy tweaks bricked the release
  pipeline while protecting nothing. Copy divergence now warns and ships
  next's text.

* feat(vscode-rollout): identity-aware namespace, authoritative activation telemetry, nightly indicator

- Derive the setting section and sdkBundle context key from the packaged
  manifest name (cline.* for stable claude-dev, cline-nightly.* for the
  nightly identity, whose packaging rewrites the whole ID namespace);
  gen-manifest derives the same prefix for gates and the injected
  bundleOverride setting.
- Call the activated bundle's reportRolloutActivation export (merged on
  both branches) with attempted/actual/fallback — the authoritative
  extension.rollout.bundle_activated event, attributed via the bundle's
  variant-built telemetry. On crash fallback the LEGACY bundle reports it.
- Rename the loader's direct PostHog event to
  extension.rollout.loader_decision: it collided byte-for-byte with the
  bundles' event name under a different schema. It keeps the loader-side
  metadata (override, launch cadence, loader_version, extension_name) and
  gains double_failure for the both-bundles-dead case.
- Fix duplicate activation events on crash fallback: the recursive legacy
  activation no longer emits a second, contradictory fallback:false event.
- Nightly-only status bar indicator (Cline: Next / Cline: Legacy) so
  dogfooders can see which bundle a window is running.
- Union diverged engines to the newer requirement instead of hard-failing:
  main's VS Code engine (^1.101.0) has legitimately moved ahead of
  legacy-extension's (^1.84.0), which bricked every combined build.
- Smoke scenarios for all of the above.

* feat(vscode-rollout): publish the nightly as the combined A/B VSIX

Convert ext-vscode-publish-nightly.yml (cron + dispatch) from the
standalone SDK build to the combined loader + next + legacy package,
published as saoudrizwan.cline-nightly at <major>.<minor>.<unix-seconds>:

- scripts/nightlify.mjs reproduces publish-nightly.mjs's identity mutation
  (claude-dev -> cline-nightly, "cline. -> "cline-nightly., displayName,
  activity bar title) with the version as an explicit argument so ONE
  version reaches both bundle manifests and the union manifest. Runs after
  dependency install and before each bundle build.
- Both bundle builds get CLINE_ROLLOUT_VARIANT (next/legacy) in the nightly
  AND stable workflows — without it the merged rollout telemetry
  (extension_variant common prop + the authoritative bundle_activated
  capture) silently no-ops.
- dry-run dispatch input builds and uploads the installable .vsix without
  publishing or tagging; publish/tag steps are additionally gated to main,
  so the PR branch can be dispatched for pre-merge verification.
- Identity guardrails before packaging: nightly workflow asserts
  cline-nightly, the stable ab-package workflow asserts claude-dev.
- The nightly tag now records the legacy bundle sha in its message.
- README: nightly channel section (identity mapping, the two telemetry
  events and their owners, dry-run verification), and a note that the
  PostHog flags govern nightly only until the stable combined VSIX ships.

The single-bundle publish-nightly.mjs path remains for manual
feature-branch pre-release publishes; CI no longer invokes it.

* chore(vscode-rollout): harden nightly workflow gating

- Restore a job-level branch allowlist on the publish job (main + the
  rehearsal branch). Advisory defense-in-depth: the enforced gate is the
  PublishNightly environment's deployment-branch policy in repo settings,
  which must list the same branches; a dispatched branch runs its own copy
  of this file.
- Route the legacy-ref dispatch input through env instead of interpolating
  it into the run script body (script-injection hygiene; dispatch already
  requires write access).

* add otel vars to rollout build (#12316)

- Extension will not emit otel metrics to otel without these vars, so
adding those into the slow-rollout build workflow

Co-authored-by: Max Paulus 🥪 <max@cline.bot>

* fix(vscode-rollout): pass OTel env to the nightly legacy bundle build

Legacy's esbuild inlines OTEL_* at build time and its standalone publish
workflow passes them, so the combined nightly's legacy bundle was being
built with the OTel logs/metrics pipeline dead. Companion to #12316,
which fixes the same gap in ext-vscode-ab-package.yml (both bundles
there).

* feat(vscode-rollout): make the rollout two-way, remove the kill-switch

The one-way cohort + versioned kill-switch existed to avoid demoting users
whose SDK-bundle tasks aren't listed by legacy and whose rotated creds may
need a re-login. Decision: those are acceptable, temporary UX costs on an
emergency-only path — not worth a second flag and permanent mechanism
complexity (payload parsing, version scoping, killed-up-to cache format).

Now there is ONE knob: each background refresh caches exactly what
ext-sdk-bundle-rollout says for the next window. Dialing the percentage
down demotes; 0% pulls everyone back to legacy on their next reload.
Fail-safe direction preserved: only a literal boolean true promotes —
variant strings / numbers / a deleted flag all resolve to legacy; malformed
/decide responses leave the cache untouched. Local crash pinning (next
threw -> pin this version to legacy on this machine) is unchanged and
independent of the flag.

Removes KILLSWITCH_FLAG/KILLSWITCH_STATE_KEY/isVersionKilled/
normalizeKilledUpTo/compareVersions/nextCachedBundle; parseRolloutFlags
becomes parseRolloutAssignment returning the bundle to cache. Smoke
scenarios replaced with two-way promote/demote coverage.

---------

Co-authored-by: Max <maxpaulus43@gmail.com>
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-07-15 15:07:45 -07:00
Max 84c9b587a6 refactor(vscode): resolve model metadata host-side (#12130)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-07-15 13:37:02 -07:00
95 changed files with 7506 additions and 458 deletions
+205
View File
@@ -0,0 +1,205 @@
name: ext-vscode-ab-package
# Build (and optionally publish) the combined A/B VSIX: a tiny loader plus two
# complete extension bundles — `next/` from the SDK-based apps/vscode on main,
# `legacy/` from the legacy-extension branch. Cohort selection happens at
# runtime via PostHog flags; see apps/vscode-rollout/README.md for the design
# and the rollout runbook.
on:
workflow_dispatch:
inputs:
version:
description: "Combined VSIX version — must exceed every previously published version (e.g. 4.1.0)"
required: true
type: string
next-ref:
description: "Ref to build the next (SDK) bundle from"
required: true
default: "main"
type: string
legacy-ref:
description: "Ref to build the legacy bundle from"
required: true
default: "legacy-extension"
type: string
publish:
description: "Publish to the VS Code Marketplace (unchecked: just build the .vsix artifact)"
required: true
default: false
type: boolean
permissions:
contents: read
concurrency:
group: ext-vscode-ab-package-${{ github.event.inputs.version }}
cancel-in-progress: false
jobs:
package:
name: Build combined (legacy + next) VSIX
runs-on: ubuntu-latest
environment: publish
steps:
- name: Checkout next (SDK) source
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.next-ref }}
path: next-src
lfs: true
- name: Checkout legacy source
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.legacy-ref }}
path: legacy-src
lfs: true
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install next workspace dependencies
working-directory: next-src
run: bun install
# @cline/* are local workspace symlinks to source packages; apps/vscode's
# `package` script does NOT build them, so without this the esbuild step
# fails on a fresh checkout. (The nightly workflow already does this.)
- name: Build SDK packages
working-directory: next-src
run: bun run build:sdk
# Stamp the combined version into each bundle's package.json AFTER
# install and BEFORE its build: the About tab and telemetry
# extension_version read the bundle's own manifest, so without this
# the VSIX reports three different versions depending on where you
# look. (The nightly workflow gets the same alignment via nightlify.mjs.)
- name: Align next bundle version
working-directory: next-src/apps/vscode-rollout
run: node scripts/set-version.mjs --dir "$GITHUB_WORKSPACE/next-src/apps/vscode" --version "${{ github.event.inputs.version }}"
- name: Build next bundle
working-directory: next-src/apps/vscode
env:
CLINE_ENVIRONMENT: production
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
# Inlined by esbuild: attributes every telemetry event with
# extension_variant and unlocks the bundle's authoritative
# extension.rollout.bundle_activated capture. Rollout builds only.
CLINE_ROLLOUT_VARIANT: next
# Match the stable publish workflow's OpenTelemetry production defaults.
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
run: bun run package
- name: Install legacy dependencies
working-directory: legacy-src
run: |
npm --prefix apps/vscode install --include=optional
npm --prefix apps/vscode/webview-ui install --include=optional
- name: Align legacy bundle version
working-directory: next-src/apps/vscode-rollout
run: node scripts/set-version.mjs --dir "$GITHUB_WORKSPACE/legacy-src/apps/vscode" --version "${{ github.event.inputs.version }}"
- name: Build legacy bundle
working-directory: legacy-src/apps/vscode
env:
CLINE_ENVIRONMENT: production
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
CLINE_ROLLOUT_VARIANT: legacy
# Match the stable publish workflow's OpenTelemetry production defaults.
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
run: npm run package
- name: Build loader and run rollout tests
working-directory: next-src/apps/vscode-rollout
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
run: |
bun run typecheck
bun run test
bun run build:production
- name: Stitch combined VSIX staging
working-directory: next-src/apps/vscode-rollout
run: |
node scripts/stitch.mjs \
--next "$GITHUB_WORKSPACE/next-src/apps/vscode" \
--legacy "$GITHUB_WORKSPACE/legacy-src/apps/vscode" \
--loader dist/extension.js \
--version "${{ github.event.inputs.version }}" \
--out "$GITHUB_WORKSPACE/staging"
- name: Smoke-test loader against staging
working-directory: next-src/apps/vscode-rollout
run: node scripts/smoke-loader.mjs "$GITHUB_WORKSPACE/staging"
# This workflow publishes the STABLE identity. If nightlify ever leaks
# into this path the union manifest would ship under the wrong name.
# The bundle sub-manifest checks guard the set-version.mjs stamping:
# the About tab and telemetry extension_version read those files.
- name: Assert stable manifest identity
working-directory: staging
env:
EXPECTED_VERSION: ${{ github.event.inputs.version }}
run: |
node -e '
const assert = require("node:assert");
const expected = process.env.EXPECTED_VERSION;
const pkg = require("./package.json");
assert.equal(pkg.name, "claude-dev", `unexpected name ${pkg.name}`);
assert.equal(pkg.publisher, "saoudrizwan", `unexpected publisher ${pkg.publisher}`);
assert.equal(pkg.version, expected, `unexpected union version ${pkg.version}`);
for (const bundle of ["next", "legacy"]) {
const sub = require(`./${bundle}/package.json`);
assert.equal(sub.version, expected, `unexpected ${bundle} bundle version ${sub.version}`);
}
console.log(`stable identity ok: ${pkg.publisher}.${pkg.name}@${pkg.version} (bundle versions aligned)`);
'
- name: Package VSIX
working-directory: staging
run: |
npm install -g @vscode/vsce
# Preserve the narrowly scoped VSCE `sendgrid` scanner exemption used by
# both standalone bundle workflows. No SendGrid credential is intentionally
# supplied here; inspect the reported artifact before widening the exemption.
vsce package --no-dependencies --allow-package-secrets sendgrid --out "claude-dev-${{ github.event.inputs.version }}.vsix"
- name: Upload VSIX artifact
uses: actions/upload-artifact@v4
with:
name: claude-dev-${{ github.event.inputs.version }}
path: staging/claude-dev-${{ github.event.inputs.version }}.vsix
if-no-files-found: error
- name: Publish to Marketplace
if: ${{ github.event.inputs.publish == 'true' }}
working-directory: staging
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
run: |
if [[ -z "$VSCE_PAT" ]]; then
echo "Error: VSCE_PAT is required to publish."
exit 1
fi
vsce publish --no-dependencies --packagePath "claude-dev-${{ github.event.inputs.version }}.vsix"
+211 -47
View File
@@ -1,17 +1,40 @@
name: ext-vscode-publish-nightly
# Publishes saoudrizwan.cline-nightly as the COMBINED A/B VSIX: the rollout
# loader plus two complete extension bundles — `next/` from this ref's
# apps/vscode (SDK-based) and `legacy/` from the legacy-extension branch.
# Cohort selection happens at runtime via PostHog flags; see
# apps/vscode-rollout/README.md for the design and rollout runbook.
#
# The stable-identity equivalent of this pipeline is ext-vscode-ab-package.yml
# (manual dispatch, publishes claude-dev). Shared logic lives in
# apps/vscode-rollout/scripts (nightlify/gen-manifest/stitch/smoke) so both
# workflows stay thin. The single-bundle nightly path this replaced
# (apps/vscode/scripts/publish-nightly.mjs) remains for manual feature-branch
# pre-release publishes.
on:
schedule:
# Every day at 4:00 AM PST (12:00 UTC)
- cron: "0 12 * * *"
workflow_dispatch:
inputs:
legacy-ref:
description: "Ref to build the legacy bundle from"
required: false
default: "legacy-extension"
type: string
dry-run:
description: "Build and upload the .vsix artifact without publishing or tagging"
required: false
default: false
type: boolean
run-name: "Publish Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
run-name: "Publish Combined Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
# Prevent concurrent publish runs on the same branch. The nightly publish script
# generates the extension version from a seconds-resolution timestamp, so parallel
# runs on the same ref can collide on the same version and cause publish failures
# or inconsistent tagging. Runs on different branches proceed independently.
# Prevent concurrent publish runs on the same branch: the version is generated
# from a seconds-resolution timestamp, so parallel runs on the same ref can
# collide on the same version and cause publish failures or inconsistent tagging.
concurrency:
group: ext-vscode-publish-nightly-${{ github.ref }}
cancel-in-progress: false
@@ -20,7 +43,7 @@ permissions: {}
jobs:
test:
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
if: github.repository == 'cline/cline'
permissions:
contents: read
pull-requests: read
@@ -30,60 +53,79 @@ jobs:
needs: test
permissions:
contents: write
name: Publish Cline (Nightly) Extension
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
name: Publish Cline (Nightly) Combined Extension
# Defense in depth: only protected main may enter the publishing environment.
# This `if` is advisory because a dispatched branch runs its own copy of this
# file; the enforced gate is the PublishNightly environment's deployment-branch
# policy, which must also allow only main.
if: github.repository == 'cline/cline' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: PublishNightly
# The VS Code extension's package.json and lockfiles live under apps/vscode/
# (the repo root has no package.json). Mirror ext-vscode-test.yml so install
# and publish steps run in the correct workspace.
defaults:
run:
working-directory: apps/vscode
steps:
- name: Checkout selected branch
- name: Checkout next (SDK) source
uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
path: next-src
lfs: true
persist-credentials: false
- name: Show build source
working-directory: ${{ github.workspace }}
- name: Checkout legacy source
uses: actions/checkout@v4
with:
# NOTE: inputs are empty strings on `schedule` events, so the ||
# fallback (not the input's declared default) is what the cron uses.
ref: ${{ inputs.legacy-ref || 'legacy-extension' }}
path: legacy-src
lfs: true
persist-credentials: false
- name: Show build sources
env:
# Routed through env rather than interpolated into the script body so
# a crafted dispatch input can't inject shell (hygiene: dispatchers
# need write access anyway, but keep the pattern clean).
LEGACY_REF: ${{ inputs.legacy-ref || 'legacy-extension' }}
run: |
echo "Building ref: $GITHUB_REF"
echo "Building sha: $GITHUB_SHA"
git --no-pager log -1 --oneline
echo "next: $(git -C next-src rev-parse HEAD)"
echo "legacy: $(git -C legacy-src rev-parse HEAD) ($LEGACY_REF)"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
# Node is still REQUIRED in the publish job (not just for install): the publish
# scripts run as `node ./scripts/publish-*.mjs` and shell out to `npx ovsx`.
# setup-bun does not provide a Node runtime, so keep setup-node here.
# Pinned to Node 22 because newer LTS (Node 24 / npm 11) can make vsce's
# `npm list` dependency detection fail with ELSPROBLEMS during packaging.
# Node is required beyond install: the rollout scripts run under node and
# publishing shells out to vsce/ovsx. Pinned to Node 22 because newer LTS
# (Node 24 / npm 11) can make vsce's dependency detection fail.
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
# Single root install resolves the whole bun workspace at once (replaces the
# per-package `npm ci` steps for apps/vscode + webview-ui).
- name: Install workspace dependencies
working-directory: ${{ github.workspace }}
# ONE version for the next bundle, the legacy bundle, and the union
# manifest: gen-manifest hard-fails if the bundle identities diverge.
# Same scheme as the standalone nightly: <major>.<minor>.<unix-seconds>
# from next's base version, so it keeps outranking earlier nightlies.
- name: Compute nightly version
id: version
run: |
BASE=$(node -p "require('./next-src/apps/vscode/package.json').version")
VERSION="$(echo "$BASE" | cut -d. -f1,2).$(date +%s)"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Combined nightly version: $VERSION (base $BASE)"
- name: Install next workspace dependencies
working-directory: next-src
run: bun install --frozen-lockfile
# @cline/* are local workspace symlinks to source packages; build dist/ before
# packaging/publishing the extension.
- name: Build SDK packages
working-directory: ${{ github.workspace }}
working-directory: next-src
run: bun run build:sdk
- name: Assert better-sqlite3 native binary present
working-directory: next-src/apps/vscode
run: |
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
if [ ! -f "$NODE_FILE" ]; then
@@ -93,20 +135,24 @@ jobs:
fi
echo "Found better-sqlite3 native binary: $NODE_FILE"
# vsce is now a workspace devDependency (on node_modules/.bin), but ovsx is not
# vendored and the publish script invokes it via `npx ovsx`, so install ovsx
# globally here (npm is available via setup-node). vsce is installed globally
# too to preserve the script's existing PATH expectations.
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
# Rewrite each bundle's package.json to the cline-nightly identity BEFORE
# its build (runtime command/config IDs derive from the manifest) and
# AFTER dependency install (workspace self-links key off the original
# package name).
- name: Nightlify next bundle manifest
working-directory: next-src/apps/vscode-rollout
run: node scripts/nightlify.mjs --dir "$GITHUB_WORKSPACE/next-src/apps/vscode" --version "${{ steps.version.outputs.version }}"
- name: Publish Nightly Extension
- name: Build next bundle
working-directory: next-src/apps/vscode
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
CLINE_ENVIRONMENT: production
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
CLINE_ENVIRONMENT: production
# Inlined by esbuild: attributes every telemetry event with
# extension_variant and unlocks the bundle's authoritative
# extension.rollout.bundle_activated capture. Rollout builds only.
CLINE_ROLLOUT_VARIANT: next
# OpenTelemetry production defaults (can be overridden at runtime)
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
@@ -114,12 +160,129 @@ jobs:
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
# The script itself runs under `node ./scripts/publish-nightly.mjs`; bun run
# just launches it. Node + npm (for `npx ovsx`) are provided by setup-node above.
run: bun run publish:marketplace:nightly
run: bun run package
- name: Install legacy dependencies
working-directory: legacy-src
run: |
npm --prefix apps/vscode install --include=optional
npm --prefix apps/vscode/webview-ui install --include=optional
- name: Nightlify legacy bundle manifest
working-directory: next-src/apps/vscode-rollout
run: node scripts/nightlify.mjs --dir "$GITHUB_WORKSPACE/legacy-src/apps/vscode" --version "${{ steps.version.outputs.version }}"
- name: Build legacy bundle
working-directory: legacy-src/apps/vscode
env:
CLINE_ENVIRONMENT: production
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
CLINE_ROLLOUT_VARIANT: legacy
# Legacy's esbuild inlines these too (its own publish workflow passes
# them) — omitting them here would ship the legacy bundle with the
# OTel pipeline dead, unlike what legacy users get today.
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
run: npm run package
- name: Build loader and run rollout tests
working-directory: next-src/apps/vscode-rollout
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
run: |
bun run typecheck
bun run test
bun run build:production
- name: Stitch combined VSIX staging
working-directory: next-src/apps/vscode-rollout
run: |
node scripts/stitch.mjs \
--next "$GITHUB_WORKSPACE/next-src/apps/vscode" \
--legacy "$GITHUB_WORKSPACE/legacy-src/apps/vscode" \
--loader dist/extension.js \
--version "${{ steps.version.outputs.version }}" \
--out "$GITHUB_WORKSPACE/staging"
- name: Smoke-test loader against staging
working-directory: next-src/apps/vscode-rollout
run: node scripts/smoke-loader.mjs "$GITHUB_WORKSPACE/staging"
# The nightly identity must have fully propagated (nightlify -> both
# bundle manifests -> union manifest) or we'd publish over the stable
# extension ID. The bundle sub-manifest checks guard the version
# stamping: the About tab and telemetry extension_version read those.
- name: Assert nightly manifest identity
working-directory: staging
env:
EXPECTED_VERSION: ${{ steps.version.outputs.version }}
run: |
node -e '
const assert = require("node:assert");
const expected = process.env.EXPECTED_VERSION;
const pkg = require("./package.json");
assert.equal(pkg.name, "cline-nightly", `unexpected name ${pkg.name}`);
assert.equal(pkg.publisher, "saoudrizwan", `unexpected publisher ${pkg.publisher}`);
assert.equal(pkg.version, expected, `unexpected union version ${pkg.version}`);
for (const bundle of ["next", "legacy"]) {
const sub = require(`./${bundle}/package.json`);
assert.equal(sub.name, "cline-nightly", `unexpected ${bundle} bundle name ${sub.name}`);
assert.equal(sub.version, expected, `unexpected ${bundle} bundle version ${sub.version}`);
}
console.log(`nightly identity ok: ${pkg.publisher}.${pkg.name}@${pkg.version} (bundle identities aligned)`);
'
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
- name: Package VSIX
working-directory: staging
# Preserve the narrowly scoped VSCE `sendgrid` scanner exemption used by
# both standalone bundle workflows. No SendGrid credential is intentionally
# supplied here; inspect the reported artifact before widening the exemption.
run: vsce package --no-dependencies --allow-package-secrets sendgrid --out "cline-nightly-${{ steps.version.outputs.version }}.vsix"
- name: Upload VSIX artifact
uses: actions/upload-artifact@v4
with:
name: cline-nightly-${{ steps.version.outputs.version }}
path: staging/cline-nightly-${{ steps.version.outputs.version }}.vsix
if-no-files-found: error
# The job is main-only; step-level dry-run gating still permits a build-only
# rehearsal without publishing or tagging.
- name: Publish to VS Code Marketplace and Open VSX
if: github.ref == 'refs/heads/main' && inputs.dry-run != true
working-directory: staging
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
run: |
if [[ -z "$VSCE_PAT" ]]; then
echo "Error: VSCE_PAT is required to publish."
exit 1
fi
vsce publish --no-dependencies --packagePath "cline-nightly-${{ steps.version.outputs.version }}.vsix"
if [[ -n "$OVSX_PAT" ]]; then
npx ovsx publish --packagePath "cline-nightly-${{ steps.version.outputs.version }}.vsix" --pat "$OVSX_PAT"
else
echo "WARNING: OVSX_PAT not set; skipping Open VSX publish."
fi
- name: Tag published commit
working-directory: ${{ github.workspace }}
if: github.ref == 'refs/heads/main' && inputs.dry-run != true
# Best-effort bookkeeping: the default GITHUB_TOKEN cannot create a ref
# whose commit modifies workflow files (no workflows permission exists
# for it), so this step fails whenever HEAD touched .github/workflows.
# The publish already succeeded by this point — don't mark the run red;
# push the tag manually with user credentials when it matters.
continue-on-error: true
working-directory: next-src
env:
GH_TOKEN: ${{ github.token }}
run: |
@@ -127,10 +290,11 @@ jobs:
SHORT_SHA=$(git rev-parse --short=12 HEAD)
TIMESTAMP=$(date -u +"%Y%m%d%H%M%S")
TAG="nightly-${SAFE_REF}-${TIMESTAMP}-${SHORT_SHA}"
LEGACY_SHA=$(git -C "$GITHUB_WORKSPACE/legacy-src" rev-parse HEAD)
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "$TAG" -m "Cline Nightly published from ${GITHUB_REF_NAME} at ${GITHUB_SHA}"
git tag -a "$TAG" -m "Cline Nightly (combined A/B) published from ${GITHUB_REF_NAME} at ${GITHUB_SHA} (legacy bundle: ${LEGACY_SHA})"
# Use an explicit HTTPS remote with GH_TOKEN because checkout was run with
# persist-credentials: false, so actions/checkout did not persist a git credential helper.
git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "refs/tags/${TAG}"
+4
View File
@@ -1,5 +1,9 @@
# Cline CLI Changelog
## 3.0.42
- Fixed Ollama native API routing so context window and timeout settings work again
## 3.0.41
- Compaction now shows progress status in the TUI
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.41",
"version": "3.0.42",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+202
View File
@@ -0,0 +1,202 @@
# vscode-rollout — A/B loader for the SDK extension rollout
The VS Code Marketplace has no staged rollouts: publishing a version updates
every user. This package lets us ship the **SDK-based extension** (main's
`apps/vscode`, "next") to a percentage of users while everyone else keeps
running the **legacy extension** (the `legacy-extension` branch), inside a
single published VSIX.
## How it works
The published VSIX contains a ~40 KB loader as its entrypoint and two complete,
independently built extension bundles:
```
extension.js ← loader (this package)
package.json ← UNION of both bundles' manifests (generated, see below)
assets/, walkthrough/ ← manifest-referenced resources (VSIX-root-relative)
next/ ← SDK extension (dist/, webview-ui/build/, assets/)
legacy/ ← legacy extension (dist/, webview-ui/build/, assets/, codicons)
```
Per window, the loader:
1. Reads the cached cohort assignment from its own `globalState` keys —
synchronously, never from the network.
2. Sets the `cline.sdkBundle` context key (gates cohort-specific menu items /
palette entries in the union manifest).
3. `require()`s exactly one bundle and calls its `activate()` with a
Proxy-wrapped `ExtensionContext` whose `extensionUri` / `extensionPath` /
`asAbsolutePath` point into that bundle's subdirectory — so each bundle
resolves its own webview build and assets without knowing it was relocated.
Storage properties pass through untouched: both bundles share the same
`~/.cline/data` + VS Code storage they used as standalone extensions.
4. After the selected bundle activates, evaluates the PostHog flags in the
background and caches the assignment **for the next window**. Flag changes
never flip a live window. A crash fallback skips this refresh so it cannot
overwrite the legacy pin.
If the next bundle throws during activation, the loader disposes whatever it
half-registered, pins this VSIX version back to legacy
(`cline.rollout.nextActivationFailedVersion`), reports a `fallback` telemetry
event, and activates legacy — a crashed rollout self-heals without a
marketplace re-publish. A new version gets to try next again.
## Cohort rules
- **Two-way, one knob.** `ext-sdk-bundle-rollout` (percentage flag) is the
entire remote control surface: each background refresh caches exactly what
the flag says for the machine's next window. Dialing the percentage up
promotes; dialing it down demotes on the next reload — the emergency lever
is simply "set the rollout to 0%". Known demotion costs (accepted): tasks
created on the SDK bundle are stored as SDK sessions the legacy bundle
doesn't list (they reappear on re-promotion — nothing is deleted), and
credentials rotated on next may require a re-login on legacy.
- **The flag must stay a boolean flag.** The loader only promotes on a
literal `true` from `/decide` — a multivariate variant, number, or anything
else fails safe to legacy (see `parseRolloutAssignment` + tests). Don't
convert it to multivariate.
- The flag is evaluated against the same PostHog distinct id the extension's
telemetry uses (machine id, mirroring `src/services/logging/distinctId.ts`),
so cohort membership is correlatable with telemetry. Flag evaluation is
always on (matching `FeatureFlagsService`); the loader's own
`extension.rollout.loader_decision` event respects the user's telemetry
opt-out and VS Code's global telemetry switch.
- **Manual overrides, in either direction.** The `cline.rollout.bundleOverride`
user setting (`"auto" | "next" | "legacy"`, editable straight from
settings.json) forces a bundle for anyone — users in a pinch, or us
debugging — beating the remote assignment both ways. Applies on window
reload. `CLINE_BUNDLE_OVERRIDE=next|legacy` (env var) does the same for
local dev and e2e and beats even the setting. Both are reported as
`override` on the loader event so overridden machines don't pollute
cohort comparisons.
- **Crash pinning is local, not remote.** If the next bundle throws during
activation, the loader falls back to legacy in the same window and pins
that VSIX version on this machine (`cline.rollout.nextActivationFailedVersion`);
a new release gets to try next again. This safety net is independent of the
flag.
## The union manifest
`package.json` contributions are static — VS Code reads them before any code
runs — so the shipped manifest must serve both cohorts. `scripts/gen-manifest.mjs`
regenerates it at stitch time from both branches' real manifests:
- Contributions declared by both bundles pass through untouched.
- Menu entries / keybindings declared by only one get `when` AND-ed with
`cline.sdkBundle` / `!cline.sdkBundle`, so a cohort never sees a button its
bundle didn't register (and shared buttons that moved position don't render
twice).
- Commands exclusive to one bundle are hidden from the other cohort's command
palette.
- `views` / `viewsContainers` / `configuration` / `walkthroughs`
**must be identical** in both manifests — they can't be safely gated at
runtime, so divergence fails the build. Keep these static contributions in
sync between the branches. `engines` may diverge: the union takes the newer
requirement (which necessarily satisfies the older one).
Because the manifest is regenerated from both branches on every build,
contribution drift between the branches can't ship silently — it either merges
cleanly or the stitch fails.
## Building locally
```bash
# 1. build both bundles (their own toolchains)
cd apps/vscode && bun run package # next
cd <legacy worktree>/apps/vscode && npm run package # legacy (npm ci first)
# 2. build the loader + stitch + package
cd apps/vscode-rollout
bun run build # dev build; CI uses build:production with the PostHog key
node scripts/stitch.mjs \
--next ../vscode --legacy <legacy worktree>/apps/vscode \
--loader dist/extension.js --version 4.1.0 --out /tmp/cline-ab-staging
node scripts/smoke-loader.mjs /tmp/cline-ab-staging # loader behavior smoke
cd /tmp/cline-ab-staging && vsce package --no-dependencies --allow-package-secrets sendgrid
```
The narrowly scoped `sendgrid` scanner exemption mirrors the existing next and
legacy packaging workflows. This workflow supplies only the existing PostHog
project-key inputs; it does not declare a SendGrid credential. Identify the
exact matching string in production staging output before changing or
broadening the exemption.
Local builds have no `TELEMETRY_SERVICE_API_KEY`, so the loader skips PostHog
entirely and everyone stays on legacy unless `CLINE_BUNDLE_OVERRIDE` is set.
CI: the `ext-vscode-ab-package` workflow (manual dispatch) builds both refs,
stitches, smoke-tests, uploads the `.vsix` artifact, and optionally publishes.
## Nightly channel
The daily `ext-vscode-publish-nightly` workflow (cron + manual dispatch)
publishes this same combined package as **`saoudrizwan.cline-nightly`**. Before
each bundle builds, `scripts/nightlify.mjs` rewrites its manifest to the
nightly identity — the same mutation the standalone nightly always applied
(`apps/vscode/scripts/publish-nightly.mjs` on both branches is the source of
truth), so nightly can be installed alongside stable:
| | stable | nightly |
|---|---|---|
| manifest `name` | `claude-dev` | `cline-nightly` |
| contribution IDs / context key / settings | `cline.*` | `cline-nightly.*` |
| version | operator-supplied (4.1.0+) | `<major>.<minor>.<unix-seconds>` |
The loader derives the namespace from its own `packageJSON.name` at runtime
(`idPrefix` in `src/cohort.ts`), and gen-manifest derives it from the next
manifest's name — no build flags involved. Nightly builds also show a
status-bar indicator (`Cline: Next` / `Cline: Legacy`); stable builds never do.
Dispatching the nightly workflow from `main` with `dry-run` builds and uploads
the installable `.vsix` without publishing or tagging. The publish job is
intentionally restricted to `main` by both the workflow and the
`PublishNightly` environment's deployment-branch policy.
### Telemetry events
- **`extension.rollout.bundle_activated`** (authoritative, captured by the
activated bundle's own telemetry via its `reportRolloutActivation` export;
requires the bundle to be built with `CLINE_ROLLOUT_VARIANT`): attempted vs
actual bundle, fallback flag, error details on fallback. Every other event
from a rollout build carries `extension_variant` as a common property.
- **`extension.rollout.loader_decision`** (loader-owned, direct capture): the
loader-side metadata the bundle event can't know — override source, launch
cadence, loader version, `extension_name` (nightly vs stable) — and the only
signal when BOTH bundles fail (`double_failure: true`).
## Rollout runbook
Until the stable combined VSIX ships, the flag governs **nightly installs
only** — dialing it is safe for production users and is the lever for moving
nightly dogfooders onto next.
1. Create `ext-sdk-bundle-rollout` in PostHog **before** the first publish: a
plain boolean release flag with a percentage rollout, starting at **0%**.
(There is deliberately no kill-switch flag — the assignment is two-way, so
0% *is* the kill switch.)
2. Publish the combined VSIX (version above every previously published one).
With the rollout at 0% this release is behaviorally identical to legacy for
everyone — it only validates the loader plumbing in the wild. Watch
`extension.rollout.bundle_activated` and `extension.rollout.loader_decision`.
3. Dial `ext-sdk-bundle-rollout` up: 1% → 5% → 25% → 100%. Assignments apply on
each machine's next window reload after its flag refresh, so propagation
speed is bounded by how often people reload windows — watch the
`ms_since_last_activation` distribution on loader events to see real
uptake lag before deciding the next step, and compare cohorts by the
`bundle` property.
4. Emergencies: dial the percentage **down** (0% pulls everyone back to legacy
on their next reload). Demoted machines keep settings and creds; tasks
created on the SDK bundle reappear when re-promoted. Ship the fix as a
higher version, then dial back up. Machines whose next bundle *crashed*
are additionally version-pinned to legacy locally, independent of the flag.
5. When next reaches 100% and soaks, retire the loader: publish a plain SDK
extension build and delete this package.
## Version numbering
The combined VSIX owns the marketplace version line and must always exceed the
last version published from either branch (legacy stable was 4.0.x → start at
4.1.0). The bundles' own `package.json` versions ride along inside their
subdirectories for provenance; the loader reports the combined version as
`loader_version`.
+27
View File
@@ -0,0 +1,27 @@
import esbuild from "esbuild";
const production = process.argv.includes("--production");
// Same build-time secret injection scheme as apps/vscode/esbuild.mjs: CI
// provides TELEMETRY_SERVICE_API_KEY; local builds leave it undefined and the
// loader skips all PostHog calls (everyone stays on legacy).
const define = {};
if (process.env.TELEMETRY_SERVICE_API_KEY) {
define["process.env.TELEMETRY_SERVICE_API_KEY"] = JSON.stringify(
process.env.TELEMETRY_SERVICE_API_KEY,
);
}
await esbuild.build({
entryPoints: ["src/extension.ts"],
bundle: true,
outfile: "dist/extension.js",
platform: "node",
format: "cjs",
target: "node18",
external: ["vscode"],
minify: production,
sourcemap: !production,
define,
logLevel: "info",
});
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@cline/vscode-rollout",
"private": true,
"version": "0.1.0",
"description": "Loader + packaging tooling for the staged (A/B) rollout of the SDK-based VS Code extension alongside the legacy extension",
"type": "module",
"scripts": {
"build": "node esbuild.mjs",
"build:production": "node esbuild.mjs --production",
"typecheck": "tsc --noEmit",
"test": "bun test src scripts",
"stitch": "node scripts/stitch.mjs"
},
"devDependencies": {
"@types/node": "20.x",
"@types/vscode": "1.84.0",
"esbuild": "^0.25.0",
"node-machine-id": "^1.1.12",
"typescript": "^5.4.5"
}
}
@@ -0,0 +1,437 @@
/**
* Generate the combined VSIX's package.json as the UNION of the two bundles'
* manifests, regenerated from both branches' actual package.json files at
* stitch time so contribution drift between branches can't ship silently.
*
* Rules:
* - Identity/top-level fields come from the next (main) manifest.
* - `main` points at the loader; `version` comes from the release input.
* - commands / menus / keybindings / activationEvents / icons are unioned.
* Menu entries and keybindings present in only ONE manifest get their
* `when` clause AND-ed with the `<prefix>.sdkBundle` context key (set by the
* loader before activation; prefix follows the manifest identity — see
* src/cohort.ts idPrefix), so a cohort never sees a button whose handler
* its bundle doesn't register — and shared buttons that moved position
* don't show up twice. Commands exclusive to one bundle are likewise hidden
* from the other cohort's command palette.
* - views / viewsContainers / configuration / engines MUST be
* identical in both manifests — they can't be safely gated at runtime, so
* divergence is a hard error.
*
* Usage: node gen-manifest.mjs --next <pkg.json> --legacy <pkg.json> --version <x.y.z> [--out <file>]
*/
import { deepStrictEqual } from "node:assert";
import { readFileSync, writeFileSync } from "node:fs";
export function generateManifest(nextPkg, legacyPkg, version) {
for (const field of ["name", "publisher", "main"]) {
if (nextPkg[field] !== legacyPkg[field]) {
throw new Error(
`manifest field "${field}" differs: ${nextPkg[field]} vs ${legacyPkg[field]}`,
);
}
}
for (const field of ["views", "viewsContainers", "configuration"]) {
try {
deepStrictEqual(
nextPkg.contributes?.[field],
legacyPkg.contributes?.[field],
);
} catch {
throw new Error(
`contributes.${field} diverged between bundles — it cannot be gated at runtime; reconcile the branches`,
);
}
}
const engines = unionEngines(nextPkg.engines, legacyPkg.engines);
assertWalkthroughsCompatible(
nextPkg.contributes?.walkthroughs,
legacyPkg.contributes?.walkthroughs,
);
// The nightly packaging rewrites the whole `cline.*` ID namespace to
// `cline-nightly.*` (scripts/nightlify.mjs), so the context key and the
// injected setting must follow the manifest's identity. Keep in sync with
// idPrefix/bundleContextKey/settingSection in src/cohort.ts.
const prefix = nextPkg.name === "cline-nightly" ? "cline-nightly" : "cline";
const nextGate = `${prefix}.sdkBundle`;
const legacyGate = `!${nextGate}`;
const nc = nextPkg.contributes ?? {};
const lc = legacyPkg.contributes ?? {};
const menus = unionMenus(nc.menus, lc.menus, nextGate, legacyGate);
hideExclusiveCommandsFromPalette(
menus,
nc.commands,
lc.commands,
nextGate,
legacyGate,
);
const manifest = {
name: nextPkg.name,
displayName: nextPkg.displayName,
description: nextPkg.description,
version,
icon: nextPkg.icon,
engines,
author: nextPkg.author,
license: nextPkg.license,
publisher: nextPkg.publisher,
repository: nextPkg.repository,
homepage: nextPkg.homepage,
categories: nextPkg.categories,
keywords: nextPkg.keywords,
activationEvents: unionPrimitive(
nextPkg.activationEvents,
legacyPkg.activationEvents,
),
main: "./extension.js",
contributes: {
viewsContainers: nc.viewsContainers,
views: nc.views,
commands: unionBy(
[...(nc.commands ?? []), ...(lc.commands ?? [])],
(c) => c.command,
),
keybindings: unionGated(
nc.keybindings,
lc.keybindings,
nextGate,
legacyGate,
),
menus,
icons: unionIcons(nc.icons, lc.icons),
configuration: injectLoaderConfiguration(nc.configuration, prefix),
walkthroughs: nc.walkthroughs,
},
scripts: {},
};
assertSuperset(manifest, nextPkg, "next", nextGate);
assertSuperset(manifest, legacyPkg, "legacy", legacyGate);
return manifest;
}
/**
* The loader's own user-visible escape hatch, keyed by the manifest identity.
* Neither bundle knows about it; only the loader reads it (src/cohort.ts
* settingSection/SETTING_BUNDLE_OVERRIDE — keep the key and values in sync).
* Injected after the configuration-equality invariant so it can't mask real
* drift between the bundles.
*/
function loaderSettings(prefix) {
return {
[`${prefix}.rollout.bundleOverride`]: {
type: "string",
enum: ["auto", "next", "legacy"],
enumDescriptions: [
"Follow the remote rollout assignment.",
"Force the new (SDK-based) extension.",
"Force the previous (legacy) extension.",
],
default: "auto",
scope: "application",
markdownDescription:
"Manual override for Cline's staged extension rollout. `next` forces the new (SDK-based) extension, `legacy` forces the previous one, `auto` follows the remote rollout assignment. Takes effect on window reload.",
},
};
}
function injectLoaderConfiguration(configuration, prefix) {
const properties = { ...(configuration?.properties ?? {}) };
for (const [key, schema] of Object.entries(loaderSettings(prefix))) {
if (properties[key]) {
throw new Error(
`bundle manifests must not declare loader-owned setting ${key}`,
);
}
properties[key] = schema;
}
return { title: "Cline", ...(configuration ?? {}), properties };
}
/**
* Walkthroughs can't be gated per cohort, and their markdown at the VSIX root
* always comes from the next checkout — so requiring byte-identical manifests
* here would brick releases over copy tweaks while protecting nothing. Only
* STRUCTURE must match (walkthrough/step ids, media paths, completion events —
* the parts code and the manifest reference); when titles/descriptions
* diverge, next's copy ships for everyone and the build says so.
*/
function assertWalkthroughsCompatible(next = [], legacy = []) {
const structure = (walkthroughs) =>
walkthroughs.map((walkthrough) => ({
id: walkthrough.id,
steps: (walkthrough.steps ?? []).map((step) => ({
id: step.id,
media: step.media,
completionEvents: step.completionEvents,
when: step.when,
})),
}));
try {
deepStrictEqual(structure(next), structure(legacy));
} catch {
throw new Error(
"contributes.walkthroughs diverged structurally (ids/media/completionEvents) — reconcile the branches",
);
}
try {
deepStrictEqual(next, legacy);
} catch {
console.warn(
"warning: walkthrough titles/descriptions differ between bundles; shipping next's copy for both cohorts",
);
}
}
/**
* Engines can safely diverge in ONE direction: the union requires whichever
* bundle needs the NEWER host, which necessarily satisfies the other bundle's
* older requirement too. (main routinely bumps the VS Code engine ahead of the
* legacy branch — an equality assertion here would brick every combined build
* over that.) Non-caret/complex ranges we can't compare fail hard rather than
* guessing.
*/
function unionEngines(nextEngines = {}, legacyEngines = {}) {
const union = {};
for (const key of new Set([
...Object.keys(nextEngines),
...Object.keys(legacyEngines),
])) {
const a = nextEngines[key];
const b = legacyEngines[key];
if (a === undefined || b === undefined || a === b) {
union[key] = a ?? b;
continue;
}
const minimum = (range) => {
const match = /^\^(\d+(?:\.\d+)*)$/.exec(range);
return match?.[1];
};
const [minA, minB] = [minimum(a), minimum(b)];
if (!minA || !minB) {
throw new Error(
`engines.${key} diverged with uncomparable ranges: ${a} vs ${b}`,
);
}
union[key] = compareDotted(minA, minB) >= 0 ? a : b;
console.warn(
`warning: engines.${key} differs between bundles (next ${a}, legacy ${b}); union requires ${union[key]}`,
);
}
return union;
}
/** Compare dotted numeric versions. */
function compareDotted(a, b) {
const pa = a.split(".").map((part) => Number.parseInt(part, 10) || 0);
const pb = b.split(".").map((part) => Number.parseInt(part, 10) || 0);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
if (diff !== 0) {
return diff;
}
}
return 0;
}
function unionPrimitive(a = [], b = []) {
return [...new Set([...a, ...b])];
}
/** Union keeping first occurrence per key (next wins on shared ids). */
function unionBy(items, keyFn) {
const seen = new Map();
for (const item of items) {
const key = keyFn(item);
if (!seen.has(key)) {
seen.set(key, item);
}
}
return [...seen.values()];
}
function sortKeysDeep(value) {
if (Array.isArray(value)) {
return value.map(sortKeysDeep);
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.keys(value)
.sort()
.map((key) => [key, sortKeysDeep(value[key])]),
);
}
return value;
}
function stableJson(value) {
return JSON.stringify(sortKeysDeep(value));
}
function gateWhen(entry, gate) {
return { ...entry, when: entry.when ? `(${entry.when}) && ${gate}` : gate };
}
/**
* Union two entry lists (menu entries or keybindings): entries declared by
* both bundles pass through untouched; entries declared by only one get their
* `when` AND-ed with that bundle's cohort gate.
*/
function unionGated(
nextEntries = [],
legacyEntries = [],
nextGate,
legacyGate,
) {
const nextSet = new Set(nextEntries.map(stableJson));
const legacySet = new Set(legacyEntries.map(stableJson));
const entries = [];
for (const entry of nextEntries) {
entries.push(
legacySet.has(stableJson(entry)) ? entry : gateWhen(entry, nextGate),
);
}
for (const entry of legacyEntries) {
if (!nextSet.has(stableJson(entry))) {
entries.push(gateWhen(entry, legacyGate));
}
}
return entries;
}
function unionMenus(a = {}, b = {}, nextGate, legacyGate) {
const menus = {};
for (const location of new Set([...Object.keys(a), ...Object.keys(b)])) {
menus[location] = unionGated(
a[location],
b[location],
nextGate,
legacyGate,
);
}
return menus;
}
/**
* A command declared by only one bundle would surface in the other cohort's
* command palette with no registered handler ("command not found" on run).
* Hide it there unless that bundle's own manifest already constrains it.
*/
function hideExclusiveCommandsFromPalette(
menus,
nextCommands = [],
legacyCommands = [],
nextGate,
legacyGate,
) {
const nextIds = new Set(nextCommands.map((c) => c.command));
const legacyIds = new Set(legacyCommands.map((c) => c.command));
const palette = menus.commandPalette ?? (menus.commandPalette = []);
const alreadyListed = new Set(palette.map((e) => e.command));
for (const id of nextIds) {
if (!legacyIds.has(id) && !alreadyListed.has(id)) {
palette.push({ command: id, when: nextGate });
}
}
for (const id of legacyIds) {
if (!nextIds.has(id) && !alreadyListed.has(id)) {
palette.push({ command: id, when: legacyGate });
}
}
}
function unionIcons(a = {}, b = {}) {
const icons = { ...b, ...a };
for (const id of Object.keys(icons)) {
if (a[id] && b[id] && JSON.stringify(a[id]) !== JSON.stringify(b[id])) {
throw new Error(
`contributes.icons["${id}"] diverged between bundles — icon fonts resolve from the VSIX root`,
);
}
}
return icons;
}
/**
* Every command/keybinding/menu entry/activation event a bundle declares must
* survive the union, either verbatim or with its `when` AND-ed with that
* bundle's cohort gate.
*/
function assertSuperset(manifest, sourcePkg, label, gate) {
const missing = [];
const commandIds = new Set(
manifest.contributes.commands.map((c) => c.command),
);
for (const cmd of sourcePkg.contributes?.commands ?? []) {
if (!commandIds.has(cmd.command)) {
missing.push(`command ${cmd.command}`);
}
}
for (const event of sourcePkg.activationEvents ?? []) {
if (!manifest.activationEvents.includes(event)) {
missing.push(`activationEvent ${event}`);
}
}
const presentOrGated = (unionEntries, entry) => {
const set = new Set((unionEntries ?? []).map(stableJson));
return (
set.has(stableJson(entry)) || set.has(stableJson(gateWhen(entry, gate)))
);
};
for (const kb of sourcePkg.contributes?.keybindings ?? []) {
if (!presentOrGated(manifest.contributes.keybindings, kb)) {
missing.push(`keybinding ${kb.command}`);
}
}
for (const [location, entries] of Object.entries(
sourcePkg.contributes?.menus ?? {},
)) {
for (const entry of entries) {
if (!presentOrGated(manifest.contributes.menus[location], entry)) {
missing.push(
`menu ${location}: ${entry.command ?? JSON.stringify(entry)}`,
);
}
}
}
if (missing.length > 0) {
throw new Error(
`union manifest is missing ${label} contributions:\n ${missing.join("\n ")}`,
);
}
}
function parseArgs(argv) {
const args = {};
for (let i = 2; i < argv.length; i += 2) {
args[argv[i].replace(/^--/, "")] = argv[i + 1];
}
return args;
}
if (import.meta.url === `file://${process.argv[1]}`) {
const { next, legacy, version, out } = parseArgs(process.argv);
if (!next || !legacy || !version) {
console.error(
"usage: gen-manifest.mjs --next <pkg.json> --legacy <pkg.json> --version <x.y.z> [--out <file>]",
);
process.exit(1);
}
const manifest = generateManifest(
JSON.parse(readFileSync(next, "utf8")),
JSON.parse(readFileSync(legacy, "utf8")),
version,
);
const json = `${JSON.stringify(manifest, null, "\t")}\n`;
if (out) {
writeFileSync(out, json);
console.log(`wrote ${out}`);
} else {
process.stdout.write(json);
}
}
@@ -0,0 +1,382 @@
import { describe, expect, it } from "bun:test";
import { generateManifest } from "./gen-manifest.mjs";
const shared = {
name: "claude-dev",
publisher: "saoudrizwan",
main: "./dist/extension.js",
engines: { vscode: "^1.84.0" },
displayName: "Cline",
};
function pkg(overrides) {
return {
...shared,
activationEvents: ["onStartupFinished"],
contributes: {
viewsContainers: {
activitybar: [{ id: "c", title: "Cline", icon: "assets/icon.svg" }],
},
views: { c: [{ type: "webview", id: "claude-dev.SidebarProvider" }] },
commands: [],
keybindings: [],
menus: {},
icons: {},
...overrides.contributes,
},
...Object.fromEntries(
Object.entries(overrides).filter(([k]) => k !== "contributes"),
),
};
}
describe("generateManifest", () => {
it("unions commands, menus, keybindings and activation events", () => {
const next = pkg({
contributes: {
commands: [
{ command: "cline.a", title: "A" },
{ command: "cline.shared", title: "S" },
],
menus: { "view/title": [{ command: "cline.a", when: "x" }] },
},
});
const legacy = pkg({
activationEvents: ["onStartupFinished", "workspaceContains:evals.env"],
contributes: {
commands: [
{ command: "cline.b", title: "B" },
{ command: "cline.shared", title: "S" },
],
menus: {
"view/title": [{ command: "cline.b", when: "y" }],
"comments/commentThread/title": [{ command: "cline.b" }],
},
keybindings: [{ command: "cline.b", key: "ctrl+k" }],
},
});
const manifest = generateManifest(next, legacy, "4.1.0");
expect(manifest.version).toBe("4.1.0");
expect(manifest.main).toBe("./extension.js");
expect(manifest.contributes.commands.map((c) => c.command).sort()).toEqual([
"cline.a",
"cline.b",
"cline.shared",
]);
expect(manifest.contributes.menus["view/title"]).toHaveLength(2);
expect(
manifest.contributes.menus["comments/commentThread/title"],
).toHaveLength(1);
expect(manifest.contributes.keybindings).toHaveLength(1);
expect(manifest.activationEvents).toContain("workspaceContains:evals.env");
});
it("gates cohort-exclusive menu entries and keybindings on the context key", () => {
const next = pkg({
contributes: {
commands: [
{ command: "cline.a", title: "A" },
{ command: "cline.shared", title: "S" },
],
menus: {
"view/title": [
{ command: "cline.a", when: "x" },
{ command: "cline.shared", when: "v" },
],
},
},
});
const legacy = pkg({
contributes: {
commands: [
{ command: "cline.b", title: "B" },
{ command: "cline.shared", title: "S" },
],
menus: {
"view/title": [
{ command: "cline.b", when: "y" },
{ command: "cline.shared", when: "v" },
],
},
keybindings: [{ command: "cline.b", key: "ctrl+k", when: "focus" }],
},
});
const manifest = generateManifest(next, legacy, "4.1.0");
const viewTitle = manifest.contributes.menus["view/title"];
expect(viewTitle.find((e) => e.command === "cline.a").when).toBe(
"(x) && cline.sdkBundle",
);
expect(viewTitle.find((e) => e.command === "cline.b").when).toBe(
"(y) && !cline.sdkBundle",
);
expect(viewTitle.find((e) => e.command === "cline.shared").when).toBe("v");
expect(manifest.contributes.keybindings[0].when).toBe(
"(focus) && !cline.sdkBundle",
);
});
it("hides cohort-exclusive commands from the other cohort's palette", () => {
const next = pkg({
contributes: {
commands: [
{ command: "cline.nextOnly", title: "N" },
{ command: "cline.shared", title: "S" },
],
},
});
const legacy = pkg({
contributes: {
commands: [
{ command: "cline.legacyOnly", title: "L" },
{ command: "cline.shared", title: "S" },
],
},
});
const palette = generateManifest(next, legacy, "4.1.0").contributes.menus
.commandPalette;
expect(palette).toContainEqual({
command: "cline.nextOnly",
when: "cline.sdkBundle",
});
expect(palette).toContainEqual({
command: "cline.legacyOnly",
when: "!cline.sdkBundle",
});
expect(palette.find((e) => e.command === "cline.shared")).toBeUndefined();
});
it("leaves commands alone when a bundle already declares a palette entry for them", () => {
const next = pkg({
contributes: { commands: [{ command: "cline.shared", title: "S" }] },
});
const legacy = pkg({
contributes: {
commands: [
{ command: "cline.hidden", title: "H" },
{ command: "cline.shared", title: "S" },
],
menus: { commandPalette: [{ command: "cline.hidden", when: "false" }] },
},
});
const palette = generateManifest(next, legacy, "4.1.0").contributes.menus
.commandPalette;
expect(palette.filter((e) => e.command === "cline.hidden")).toEqual([
{ command: "cline.hidden", when: "(false) && !cline.sdkBundle" },
]);
});
it("dedupes structurally identical menu entries", () => {
const entry = { command: "cline.a", when: "view == cline" };
const next = pkg({
contributes: {
commands: [{ command: "cline.a", title: "A" }],
menus: { "view/title": [entry] },
},
});
const legacy = pkg({
contributes: {
commands: [{ command: "cline.a", title: "A" }],
menus: { "view/title": [{ ...entry }] },
},
});
expect(
generateManifest(next, legacy, "1.0.0").contributes.menus["view/title"],
).toHaveLength(1);
});
it("rejects diverged views/viewsContainers", () => {
const next = pkg({});
const legacy = pkg({
contributes: { views: { c: [{ type: "webview", id: "other" }] } },
});
expect(() => generateManifest(next, legacy, "1.0.0")).toThrow(/views/);
});
it("rejects structurally diverged walkthroughs", () => {
const walkthrough = (stepId, media) => ({
contributes: {
walkthroughs: [
{
id: "ClineWalkthrough",
title: "Meet Cline",
steps: [{ id: stepId, title: "Start here", media }],
},
],
},
});
expect(() =>
generateManifest(
pkg(walkthrough("welcome", { markdown: "walkthrough/step1.md" })),
pkg(walkthrough("hello", { markdown: "walkthrough/step1.md" })),
"1.0.0",
),
).toThrow(/walkthroughs diverged structurally/);
expect(() =>
generateManifest(
pkg(walkthrough("welcome", { markdown: "walkthrough/step1.md" })),
pkg(walkthrough("welcome", { markdown: "walkthrough/other.md" })),
"1.0.0",
),
).toThrow(/walkthroughs diverged structurally/);
});
it("tolerates copy-only walkthrough divergence, shipping next's text", () => {
const walkthrough = (description) => ({
contributes: {
walkthroughs: [
{
id: "ClineWalkthrough",
title: "Meet Cline",
steps: [
{
id: "welcome",
title: "Start here",
description,
media: { markdown: "walkthrough/step1.md" },
},
],
},
],
},
});
const manifest = generateManifest(
pkg(walkthrough("Connect via MCP.")),
pkg(walkthrough("Discover the MCP Marketplace.")),
"1.0.0",
);
expect(manifest.contributes.walkthroughs[0].steps[0].description).toBe(
"Connect via MCP.",
);
});
it("rejects diverged configuration", () => {
const next = pkg({
contributes: {
configuration: {
title: "Cline",
properties: {
"cline.enabled": { type: "boolean", default: false },
},
},
},
});
const legacy = pkg({
contributes: {
configuration: {
title: "Cline",
properties: {
"cline.enabled": { type: "boolean", default: 0 },
},
},
},
});
expect(() => generateManifest(next, legacy, "1.0.0")).toThrow(
/contributes\.configuration diverged/,
);
});
it("injects the loader-owned bundleOverride setting into the union", () => {
const manifest = generateManifest(pkg({}), pkg({}), "4.1.0");
const prop =
manifest.contributes.configuration.properties[
"cline.rollout.bundleOverride"
];
expect(prop).toBeDefined();
expect(prop.enum).toEqual(["auto", "next", "legacy"]);
expect(prop.default).toBe("auto");
expect(prop.scope).toBe("application");
});
it("rejects bundles that declare the loader-owned setting themselves", () => {
const withClash = {
contributes: {
configuration: {
title: "Cline",
properties: { "cline.rollout.bundleOverride": { type: "string" } },
},
},
};
expect(() =>
generateManifest(pkg(withClash), pkg(withClash), "4.1.0"),
).toThrow(/loader-owned setting/);
});
it("derives gates and the injected setting from the nightly identity", () => {
const nightly = (overrides) => ({
...pkg(overrides),
name: "cline-nightly",
displayName: "Cline (Nightly)",
});
const next = nightly({
contributes: {
commands: [{ command: "cline-nightly.nextOnly", title: "N" }],
menus: {
"view/title": [{ command: "cline-nightly.nextOnly", when: "x" }],
},
},
});
const legacy = nightly({
contributes: {
commands: [{ command: "cline-nightly.legacyOnly", title: "L" }],
keybindings: [{ command: "cline-nightly.legacyOnly", key: "ctrl+k" }],
},
});
const manifest = generateManifest(next, legacy, "4.0.1752600000");
expect(manifest.name).toBe("cline-nightly");
expect(manifest.contributes.menus["view/title"][0].when).toBe(
"(x) && cline-nightly.sdkBundle",
);
expect(manifest.contributes.keybindings[0].when).toBe(
"!cline-nightly.sdkBundle",
);
expect(manifest.contributes.menus.commandPalette).toContainEqual({
command: "cline-nightly.legacyOnly",
when: "!cline-nightly.sdkBundle",
});
const properties = manifest.contributes.configuration.properties;
expect(properties["cline-nightly.rollout.bundleOverride"]).toBeDefined();
expect(properties["cline.rollout.bundleOverride"]).toBeUndefined();
});
it("unions diverged engines to the newer requirement (either direction)", () => {
const olderLegacy = { ...pkg({}), engines: { vscode: "^1.74.0" } };
expect(generateManifest(pkg({}), olderLegacy, "1.0.0").engines).toEqual({
vscode: "^1.84.0",
});
const newerLegacy = { ...pkg({}), engines: { vscode: "^1.101.0" } };
expect(generateManifest(pkg({}), newerLegacy, "1.0.0").engines).toEqual({
vscode: "^1.101.0",
});
});
it("rejects diverged engines it cannot compare", () => {
const legacy = { ...pkg({}), engines: { vscode: ">=1.84.0 <2.0.0" } };
expect(() => generateManifest(pkg({}), legacy, "1.0.0")).toThrow(
/uncomparable/,
);
});
it("rejects conflicting icon definitions", () => {
const next = pkg({
contributes: {
icons: {
"cline-logo": {
description: "d",
default: { fontPath: "a.woff", fontCharacter: "\\E900" },
},
},
},
});
const legacy = pkg({
contributes: {
icons: {
"cline-logo": {
description: "d",
default: { fontPath: "b.woff", fontCharacter: "\\E900" },
},
},
},
});
expect(() => generateManifest(next, legacy, "1.0.0")).toThrow(/icons/);
});
});
+82
View File
@@ -0,0 +1,82 @@
/**
* Rewrite an apps/vscode package.json to the nightly identity, in place.
*
* Reproduces updatePackageJson() from apps/vscode/scripts/publish-nightly.mjs
* (the same script exists on BOTH main and legacy-extension — those copies are
* the source of truth for the mutation; if they change, change this too):
* - textual rewrites: "claude-dev" -> "cline-nightly" everywhere, and every
* `"cline.` ID prefix -> `"cline-nightly.` (commands, settings, view IDs,
* when-clauses that START with the key — mid-string references like
* `config.cline.x` are NOT rewritten, same as the standalone nightly)
* - name / displayName / activity bar title / version
*
* Differences from publish-nightly.mjs, on purpose:
* - the version is an explicit ARGUMENT, not computed here: the combined
* VSIX applies ONE version to the next bundle, the legacy bundle, and the
* union manifest, so gen-manifest's identity-equality assertions hold.
* - no backup/restore, README swapping, or workspace-self-link reconciling:
* this runs against a disposable CI checkout, BEFORE the bundle build and
* never followed by vsce in that checkout (vsce only runs in the stitched
* staging dir with --no-dependencies).
*
* Run it AFTER dependency install (the workspace self-link resolution keys off
* the original package name) and BEFORE the bundle's package build.
*
* Usage: node nightlify.mjs --dir <apps/vscode checkout> --version <x.y.ts>
*/
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
export const NIGHTLY_NAME = "cline-nightly";
export const NIGHTLY_DISPLAY_NAME = "Cline (Nightly)";
export function nightlifyPackageJson(rawContent, version) {
if (!version) {
throw new Error("version is required");
}
const content = rawContent
.replaceAll("claude-dev", NIGHTLY_NAME)
.replaceAll('"cline.', `"${NIGHTLY_NAME}.`);
const pkg = JSON.parse(content);
pkg.name = NIGHTLY_NAME;
pkg.displayName = NIGHTLY_DISPLAY_NAME;
pkg.version = version;
// publish-nightly.mjs assigns `.title` on the activitybar value directly,
// which is a silent no-op on the real manifest (activitybar is an ARRAY —
// JSON.stringify drops non-index properties). Retitle the actual entries.
const activitybar = pkg.contributes?.viewsContainers?.activitybar;
for (const container of Array.isArray(activitybar) ? activitybar : []) {
container.title = NIGHTLY_DISPLAY_NAME;
}
return `${JSON.stringify(pkg, null, "\t")}\n`;
}
function parseArgs(argv) {
const args = {};
for (let i = 2; i < argv.length; i += 2) {
args[argv[i].replace(/^--/, "")] = argv[i + 1];
}
return args;
}
if (
process.argv[1] &&
import.meta.url === `file://${path.resolve(process.argv[1])}`
) {
const { dir, version } = parseArgs(process.argv);
if (!dir || !version) {
console.error(
"usage: node nightlify.mjs --dir <apps/vscode checkout> --version <x.y.ts>",
);
process.exit(1);
}
const packageJsonPath = path.join(dir, "package.json");
const before = readFileSync(packageJsonPath, "utf8");
const beforeName = JSON.parse(before).name;
writeFileSync(packageJsonPath, nightlifyPackageJson(before, version));
console.log(
`nightlified ${packageJsonPath}: ${beforeName} -> ${NIGHTLY_NAME}@${version}`,
);
}
@@ -0,0 +1,92 @@
import { describe, expect, it } from "bun:test";
import { nightlifyPackageJson } from "./nightlify.mjs";
const fixture = {
name: "claude-dev",
displayName: "Cline",
publisher: "saoudrizwan",
version: "4.0.0",
main: "./dist/extension.js",
contributes: {
viewsContainers: {
activitybar: [
{
id: "claude-dev-ActivityBar",
title: "Cline",
icon: "assets/icon.svg",
},
],
},
views: {
"claude-dev-ActivityBar": [
{ type: "webview", id: "claude-dev.SidebarProvider" },
],
},
commands: [{ command: "cline.plusButtonClicked", title: "New Task" }],
keybindings: [{ command: "cline.addToChat", key: "ctrl+'" }],
menus: {
"view/title": [
{
command: "cline.plusButtonClicked",
when: "view == claude-dev.SidebarProvider",
},
// Mid-string references are NOT rewritten — a known limitation
// shared with the standalone nightly's publish-nightly.mjs.
{ command: "cline.addToChat", when: "config.cline.enableExtras" },
],
},
configuration: {
title: "Cline",
properties: { "cline.enableExtras": { type: "boolean" } },
},
},
};
describe("nightlifyPackageJson", () => {
const pkg = JSON.parse(
nightlifyPackageJson(JSON.stringify(fixture, null, "\t"), "4.0.1752600000"),
);
it("sets the nightly identity and the supplied version", () => {
expect(pkg.name).toBe("cline-nightly");
expect(pkg.displayName).toBe("Cline (Nightly)");
expect(pkg.version).toBe("4.0.1752600000");
expect(pkg.publisher).toBe("saoudrizwan");
});
it("rewrites claude-dev IDs and the cline.* namespace", () => {
expect(pkg.contributes.viewsContainers.activitybar[0].id).toBe(
"cline-nightly-ActivityBar",
);
expect(pkg.contributes.viewsContainers.activitybar[0].title).toBe(
"Cline (Nightly)",
);
expect(Object.keys(pkg.contributes.views)).toEqual([
"cline-nightly-ActivityBar",
]);
expect(pkg.contributes.views["cline-nightly-ActivityBar"][0].id).toBe(
"cline-nightly.SidebarProvider",
);
expect(pkg.contributes.commands[0].command).toBe(
"cline-nightly.plusButtonClicked",
);
expect(pkg.contributes.keybindings[0].command).toBe(
"cline-nightly.addToChat",
);
expect(Object.keys(pkg.contributes.configuration.properties)).toEqual([
"cline-nightly.enableExtras",
]);
});
it("rewrites when-clauses that start with a rewritten ID, but not mid-string references", () => {
const [gated, midString] = pkg.contributes.menus["view/title"];
expect(gated.when).toBe("view == cline-nightly.SidebarProvider");
// Documented limitation: `config.cline.` does not match the `"cline.`
// pattern, so it survives unrewritten (matches publish-nightly.mjs).
expect(midString.when).toBe("config.cline.enableExtras");
});
it("requires a version", () => {
expect(() => nightlifyPackageJson("{}", undefined)).toThrow(/version/);
});
});
@@ -0,0 +1,55 @@
/**
* Stamp the combined VSIX's version into a bundle checkout's package.json,
* in place, BEFORE that bundle builds.
*
* Why: the union manifest's version (what the Marketplace and auto-update
* see) is supplied at stitch time, but each bundle's runtime reads its OWN
* package.json — the About tab and every telemetry event's extension_version
* come from there. Without this stamp the stable combined VSIX would report
* three different versions (union input, main's base version, legacy's base
* version) depending on where you look, which turns user bug reports into
* archaeology. The nightly path gets the same alignment via nightlify.mjs
* (which also rewrites identity); this script is the identity-preserving
* version-only equivalent for the stable channel.
*
* Usage: node set-version.mjs --dir <apps/vscode checkout> --version <x.y.z>
*/
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
export function setPackageVersion(rawContent, version) {
if (!version) {
throw new Error("version is required");
}
const pkg = JSON.parse(rawContent);
pkg.version = version;
return `${JSON.stringify(pkg, null, "\t")}\n`;
}
function parseArgs(argv) {
const args = {};
for (let i = 2; i < argv.length; i += 2) {
args[argv[i].replace(/^--/, "")] = argv[i + 1];
}
return args;
}
if (
process.argv[1] &&
import.meta.url === `file://${path.resolve(process.argv[1])}`
) {
const { dir, version } = parseArgs(process.argv);
if (!dir || !version) {
console.error(
"usage: node set-version.mjs --dir <apps/vscode checkout> --version <x.y.z>",
);
process.exit(1);
}
const packageJsonPath = path.join(dir, "package.json");
const before = JSON.parse(readFileSync(packageJsonPath, "utf8")).version;
writeFileSync(
packageJsonPath,
setPackageVersion(readFileSync(packageJsonPath, "utf8"), version),
);
console.log(`set ${packageJsonPath} version: ${before} -> ${version}`);
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from "bun:test";
import { setPackageVersion } from "./set-version.mjs";
const fixture = JSON.stringify(
{
name: "claude-dev",
displayName: "Cline",
publisher: "saoudrizwan",
version: "4.0.0",
contributes: {
commands: [{ command: "cline.plusButtonClicked", title: "New Task" }],
},
},
null,
"\t",
);
describe("setPackageVersion", () => {
it("stamps the version and touches nothing else", () => {
const pkg = JSON.parse(setPackageVersion(fixture, "4.1.0"));
expect(pkg.version).toBe("4.1.0");
expect(pkg.name).toBe("claude-dev");
expect(pkg.displayName).toBe("Cline");
expect(pkg.publisher).toBe("saoudrizwan");
expect(pkg.contributes.commands[0].command).toBe(
"cline.plusButtonClicked",
);
});
it("requires a version", () => {
expect(() => setPackageVersion(fixture, undefined)).toThrow(/version/);
expect(() => setPackageVersion(fixture, "")).toThrow(/version/);
});
});
@@ -0,0 +1,671 @@
/**
* Node-level smoke test for the built loader against a staged VSIX directory.
* No real VS Code: `vscode` is stubbed just enough for the loader itself, and
* the staging dir's next/legacy bundles are swapped for tiny recorders. Verifies
* the loader's end-to-end behavior in a real require() environment:
* 1. default (no cached cohort) -> activates legacy
* 2. cached cohort "next" -> activates next, scoped context paths
* 3. the flag refresh caches a TWO-WAY assignment for the next window
* (rollout on promotes, rollout off demotes a cached "next")
* 4. CLINE_BUNDLE_OVERRIDE / the cline.rollout.bundleOverride setting
* force a bundle in either direction
* 5. next activation throws -> disposes partial registrations, falls
* back to legacy, pins version, and
* skips the cohort refresh
* 6. the activated bundle's reportRolloutActivation export receives the
* authoritative attempted/actual/fallback record (and its absence is
* tolerated); the loader's own loader_decision capture fires exactly
* once per window
* 7. the nightly identity (manifest name cline-nightly) switches the
* setting section + context key namespace and shows the status bar
* bundle indicator
* 8. both bundles throwing surfaces the failure and captures a
* double_failure loader event
*
* Usage: node smoke-loader.mjs <staging-dir>
* Copies the staging dir to a temp sandbox; the input is never modified.
*/
import assert from "node:assert";
import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import Module from "node:module";
import { tmpdir } from "node:os";
import path from "node:path";
const staging = process.argv[2];
if (!staging) {
console.error("usage: node smoke-loader.mjs <staging-dir>");
process.exit(1);
}
// ---- vscode API stub (only what the loader touches) -------------------------
const executedCommands = [];
const statusBarItems = [];
function makeVscodeStub(
sandbox,
settings = {},
{ telemetryEnabled = false } = {},
) {
return {
Uri: {
file: (fsPath) => ({ fsPath, path: fsPath, scheme: "file" }),
joinPath: (base, ...segments) => {
const fsPath = path.join(base.fsPath, ...segments);
return { fsPath, path: fsPath, scheme: "file" };
},
},
commands: {
executeCommand: async (command, ...args) => {
executedCommands.push([command, ...args]);
},
},
workspace: {
getConfiguration: (section) => ({
get: (key) => settings[`${section}.${key}`],
}),
},
window: {
createStatusBarItem: () => {
const item = {
text: "",
tooltip: "",
shown: false,
show() {
this.shown = true;
},
dispose() {},
};
statusBarItems.push(item);
return item;
},
},
StatusBarAlignment: { Left: 1, Right: 2 },
env: { machineId: "smoke-machine", isTelemetryEnabled: telemetryEnabled },
version: "0.0.0-smoke",
_sandbox: sandbox,
};
}
function makeContext(sandbox, globalStateSeed = {}, packageJSON = {}) {
const state = new Map(Object.entries(globalStateSeed));
return {
extensionUri: { fsPath: sandbox, path: sandbox, scheme: "file" },
extensionPath: sandbox,
extension: { packageJSON: { version: "4.1.0-smoke", ...packageJSON } },
subscriptions: [],
globalState: {
get: (key) => state.get(key),
update: async (key, value) => void state.set(key, value),
_dump: () => Object.fromEntries(state),
},
asAbsolutePath: (rel) => path.join(sandbox, rel),
};
}
/** PostHog /capture/ POSTs recorded by a scenario's fetch stub, parsed. */
function captureCalls(fetchCalls) {
return fetchCalls
.filter(([url]) => String(url).includes("/capture/"))
.map(([, init]) => JSON.parse(init.body));
}
function captureEvents(fetchCalls, event) {
return captureCalls(fetchCalls).filter((capture) => capture.event === event);
}
function loaderDecisionCaptures(fetchCalls) {
return captureEvents(fetchCalls, "extension.rollout.loader_decision");
}
function featureFlagCalledCaptures(fetchCalls) {
return captureEvents(fetchCalls, "$feature_flag_called");
}
function decideCalls(fetchCalls) {
return fetchCalls.filter(([url]) => String(url).includes("/decide"));
}
function flagResponse(flags = { rollout: false }) {
return {
ok: true,
json: async () => ({
featureFlags: {
"ext-sdk-bundle-rollout": flags.rollout,
},
}),
};
}
function makeFlagFetch(flags) {
const calls = [];
const fetch = async (...args) => {
calls.push(args);
return flagResponse(flags);
};
return { calls, fetch };
}
function makeDeferredFlagFetch(flags) {
const calls = [];
let resolveResponse;
let markStarted;
const started = new Promise((resolve) => {
markStarted = resolve;
});
const fetch = (...args) => {
calls.push(args);
markStarted();
return new Promise((resolve) => {
resolveResponse = () => resolve(flagResponse(flags));
});
};
return {
calls,
fetch,
started,
resolve: () => resolveResponse?.(),
};
}
async function waitFor(predicate, message, timeoutMs = 500) {
const deadline = Date.now() + timeoutMs;
while (!predicate()) {
if (Date.now() >= deadline) {
assert.fail(message);
}
await new Promise((resolve) => setTimeout(resolve, 5));
}
}
async function flushAsyncWork() {
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
}
// ---- sandbox setup -----------------------------------------------------------
function makeSandbox({
nextThrows = false,
legacyThrows = false,
omitReportExport = false,
} = {}) {
const sandbox = mkdtempSync(path.join(tmpdir(), "cline-ab-smoke-"));
cpSync(
path.join(staging, "extension.js"),
path.join(sandbox, "extension.js"),
);
for (const bundle of ["next", "legacy"]) {
mkdirSync(path.join(sandbox, bundle, "dist"), { recursive: true });
const throws =
(bundle === "next" && nextThrows) ||
(bundle === "legacy" && legacyThrows);
const throwLine = throws
? `await global.__smoke.beforeNextFailure?.();\n\t\tctx.subscriptions.push({ dispose() { global.__smoke.disposed.push("${bundle}") } });\n\t\tthrow new Error("smoke: ${bundle} activation exploded");`
: "";
// Mirrors the reportRolloutActivation export both real bundles gained in
// their rollout-telemetry PRs; recorded so scenarios can assert the
// authoritative attempted/actual/fallback record.
const reportExport = omitReportExport
? ""
: `exports.reportRolloutActivation = async (input) => { global.__smoke.reports.push({ reporter: "${bundle}", attemptedBundle: input.attemptedBundle, actualBundle: input.actualBundle, fallback: input.fallback, hasError: input.error !== undefined }); };`;
writeFileSync(
path.join(sandbox, bundle, "dist", "extension.js"),
`exports.activate = async (ctx) => {
${throwLine}
global.__smoke.activated.push({ bundle: "${bundle}", extensionPath: ctx.extensionPath, asAbs: ctx.asAbsolutePath("webview-ui/build") });
return { bundle: "${bundle}" };
};
exports.deactivate = () => { global.__smoke.deactivated.push("${bundle}"); };
${reportExport}`,
);
}
mkdirSync(path.join(sandbox, "data"), { recursive: true });
writeFileSync(
path.join(sandbox, "data", "globalState.json"),
JSON.stringify({ "cline.generatedMachineId": "smoke-machine" }),
);
return sandbox;
}
async function runScenario(
name,
{
seed = {},
env = {},
settings = {},
nextThrows = false,
legacyThrows = false,
omitReportExport = false,
telemetryEnabled = false,
contextPackageJSON = {},
expectFailure = false,
fetchController = makeFlagFetch(),
beforeNextFailure,
expectRefresh = true,
},
checks,
afterDeactivateChecks = async () => {},
) {
const sandbox = makeSandbox({ nextThrows, legacyThrows, omitReportExport });
global.__smoke = {
activated: [],
deactivated: [],
disposed: [],
reports: [],
beforeNextFailure,
};
executedCommands.length = 0;
statusBarItems.length = 0;
const previousEnv = {};
const scenarioEnv = {
CLINE_DIR: sandbox,
// A dev build leaves this lookup dynamic; production builds inline the
// real PostHog key. Either way, the smoke must exercise refreshCohort.
TELEMETRY_SERVICE_API_KEY: "smoke-posthog-project-key",
...env,
};
for (const [key, value] of Object.entries(scenarioEnv)) {
previousEnv[key] = process.env[key];
process.env[key] = value;
}
const originalFetch = global.fetch;
global.fetch = fetchController.fetch;
const originalResolve = Module._resolveFilename;
Module._resolveFilename = function (request, ...rest) {
if (request === "vscode") {
return "vscode";
}
return originalResolve.call(this, request, ...rest);
};
require.cache.vscode = {
id: "vscode",
filename: "vscode",
loaded: true,
exports: makeVscodeStub(sandbox, settings, { telemetryEnabled }),
};
try {
const loaderPath = path.join(sandbox, "extension.js");
delete require.cache[loaderPath];
const loader = require(loaderPath);
const context = makeContext(sandbox, seed, contextPackageJSON);
let api;
let activationError;
try {
api = await loader.activate(context);
} catch (error) {
activationError = error;
}
if (expectFailure) {
assert.ok(activationError, `${name} should have failed to activate`);
} else if (activationError) {
throw activationError;
}
if (expectRefresh) {
await waitFor(
() => decideCalls(fetchController.calls).length > 0,
`${name} did not refresh its cohort after activation`,
);
await flushAsyncWork();
assert.equal(
decideCalls(fetchController.calls).length,
1,
`${name} should refresh its cohort exactly once`,
);
}
await checks({
context,
api,
activationError,
sandbox,
fetchCalls: fetchController.calls,
});
await loader.deactivate();
await afterDeactivateChecks({ context, api, sandbox });
console.log(`PASS ${name}`);
} finally {
Module._resolveFilename = originalResolve;
delete require.cache.vscode;
if (originalFetch === undefined) {
delete global.fetch;
} else {
global.fetch = originalFetch;
}
for (const [key, value] of Object.entries(previousEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
rmSync(sandbox, { recursive: true, force: true });
}
}
const require = Module.createRequire(import.meta.url);
await runScenario("default cohort -> legacy", {}, async ({ api, sandbox }) => {
assert.deepEqual(api, { bundle: "legacy" });
assert.equal(
global.__smoke.activated[0].extensionPath,
path.join(sandbox, "legacy"),
);
assert.deepEqual(executedCommands[0], [
"setContext",
"cline.sdkBundle",
false,
]);
assert.deepEqual(global.__smoke.deactivated, []);
// The activated bundle received the authoritative activation record.
assert.deepEqual(global.__smoke.reports, [
{
reporter: "legacy",
attemptedBundle: "legacy",
actualBundle: "legacy",
fallback: false,
hasError: false,
},
]);
// Stable identity: no nightly status bar indicator.
assert.equal(statusBarItems.length, 0);
});
await runScenario(
"cached next -> next with scoped paths",
{ seed: { "cline.rollout.bundle": "next" } },
async ({ api, sandbox }) => {
assert.deepEqual(api, { bundle: "next" });
const activation = global.__smoke.activated[0];
assert.equal(activation.extensionPath, path.join(sandbox, "next"));
assert.equal(
activation.asAbs,
path.join(sandbox, "next", "webview-ui", "build"),
);
assert.deepEqual(executedCommands[0], [
"setContext",
"cline.sdkBundle",
true,
]);
assert.deepEqual(global.__smoke.reports, [
{
reporter: "next",
attemptedBundle: "next",
actualBundle: "next",
fallback: false,
hasError: false,
},
]);
},
);
await runScenario(
"rollout flag on promotes for the NEXT window only",
{
fetchController: makeFlagFetch({ rollout: true }),
},
async ({ context, api, fetchCalls }) => {
// This window already decided legacy from the (empty) cache; the refresh
// promotes the NEXT window.
assert.deepEqual(api, { bundle: "legacy" });
assert.equal(context.globalState._dump()["cline.rollout.bundle"], "next");
const [featureFlagCalled] = featureFlagCalledCaptures(fetchCalls);
assert.ok(featureFlagCalled, "rollout refresh must emit the PostHog feature-flag exposure event");
assert.equal(featureFlagCalled.properties.$feature_flag, "ext-sdk-bundle-rollout");
assert.equal(featureFlagCalled.properties.$feature_flag_response, true);
},
);
await runScenario(
"rollout flag off demotes a cached next for the NEXT window (two-way)",
{
seed: { "cline.rollout.bundle": "next" },
fetchController: makeFlagFetch({ rollout: false }),
},
async ({ context, api, fetchCalls }) => {
// This window already ran next; dialing the flag down moves the machine
// back to legacy on its next reload.
assert.deepEqual(api, { bundle: "next" });
assert.equal(context.globalState._dump()["cline.rollout.bundle"], "legacy");
const [featureFlagCalled] = featureFlagCalledCaptures(fetchCalls);
assert.ok(featureFlagCalled, "rollout refresh must emit the PostHog feature-flag exposure event");
assert.equal(featureFlagCalled.event, "$feature_flag_called");
assert.equal(featureFlagCalled.properties.$feature_flag, "ext-sdk-bundle-rollout");
assert.equal(featureFlagCalled.properties.$feature_flag_response, false);
},
);
await runScenario(
"env override forces next",
{ env: { CLINE_BUNDLE_OVERRIDE: "next" } },
async ({ api }) => {
assert.deepEqual(api, { bundle: "next" });
},
);
await runScenario(
"user setting overrides to legacy despite cached next",
{
seed: { "cline.rollout.bundle": "next" },
settings: { "cline.rollout.bundleOverride": "legacy" },
},
async ({ api }) => {
assert.deepEqual(api, { bundle: "legacy" });
},
);
await runScenario(
"user setting overrides to next despite a cached legacy assignment",
{
seed: { "cline.rollout.bundle": "legacy" },
settings: { "cline.rollout.bundleOverride": "next" },
},
async ({ api }) => {
assert.deepEqual(api, { bundle: "next" });
},
);
const failedNextRefresh = makeDeferredFlagFetch({ rollout: true });
await runScenario(
"next activation failure falls back to legacy",
{
seed: { "cline.rollout.bundle": "next" },
nextThrows: true,
fetchController: failedNextRefresh,
expectRefresh: false,
beforeNextFailure: () =>
Promise.race([
failedNextRefresh.started,
new Promise((resolve) => setTimeout(resolve, 100)),
]),
},
async ({ context, api, fetchCalls }) => {
assert.deepEqual(api, { bundle: "legacy" });
assert.deepEqual(
global.__smoke.disposed,
["next"],
"partial registrations disposed",
);
const state = context.globalState._dump();
assert.equal(state["cline.rollout.bundle"], "legacy");
assert.equal(
state["cline.rollout.nextActivationFailedVersion"],
"4.1.0-smoke",
);
assert.equal(
context.subscriptions.length,
0,
"failed bundle's subscriptions removed",
);
// setContext flipped back for the legacy UI
assert.deepEqual(executedCommands.at(-1), [
"setContext",
"cline.sdkBundle",
false,
]);
// The LEGACY bundle (the one whose telemetry pipeline is alive) received
// the authoritative fallback record; the dead next bundle reported nothing.
assert.deepEqual(global.__smoke.reports, [
{
reporter: "legacy",
attemptedBundle: "next",
actualBundle: "legacy",
fallback: true,
hasError: true,
},
]);
// Keep the fetch stub installed long enough for an incorrectly delayed
// refresh to reach the network boundary before asserting its absence.
await new Promise((resolve) => setTimeout(resolve, 100));
// Settle a refresh if the loader incorrectly launched one. With the old
// ordering it would now promote COHORT_STATE_KEY back to next.
if (decideCalls(fetchCalls).length > 0) {
failedNextRefresh.resolve();
await flushAsyncWork();
}
assert.equal(
decideCalls(fetchCalls).length,
0,
"crash fallback must not refresh the failed cohort",
);
assert.equal(context.globalState._dump()["cline.rollout.bundle"], "legacy");
},
);
await runScenario(
"loader_decision capture carries the loader-side metadata",
{
env: { CLINE_BUNDLE_OVERRIDE: "next" },
telemetryEnabled: true,
contextPackageJSON: { name: "claude-dev" },
},
async ({ fetchCalls }) => {
await waitFor(
() => loaderDecisionCaptures(fetchCalls).length > 0,
"loader_decision capture never reached the network",
);
const captures = loaderDecisionCaptures(fetchCalls);
assert.equal(captures.length, 1);
const [capture] = captures;
assert.equal(capture.event, "extension.rollout.loader_decision");
assert.equal(capture.properties.bundle, "next");
assert.equal(capture.properties.attempted_bundle, "next");
assert.equal(capture.properties.fallback, false);
assert.equal(capture.properties.override, "env");
assert.equal(capture.properties.loader_version, "4.1.0-smoke");
assert.equal(capture.properties.extension_name, "claude-dev");
},
);
await runScenario(
"crash fallback captures exactly one loader_decision event",
{
seed: { "cline.rollout.bundle": "next" },
nextThrows: true,
telemetryEnabled: true,
expectRefresh: false,
},
async ({ fetchCalls }) => {
await waitFor(
() => loaderDecisionCaptures(fetchCalls).length > 0,
"fallback loader_decision capture never reached the network",
);
// Give an incorrect second capture (the pre-fix fallback:false event from
// the recursive legacy success) time to reach the network before counting.
await new Promise((resolve) => setTimeout(resolve, 100));
const captures = loaderDecisionCaptures(fetchCalls);
assert.equal(
captures.length,
1,
"fallback must emit exactly ONE loader event (regression: duplicate fallback:false event)",
);
const [capture] = captures;
assert.equal(capture.event, "extension.rollout.loader_decision");
assert.equal(capture.properties.bundle, "legacy");
assert.equal(capture.properties.attempted_bundle, "next");
assert.equal(capture.properties.fallback, true);
assert.match(capture.properties.error_message, /next activation exploded/);
},
);
await runScenario(
"a bundle without the reportRolloutActivation export still activates",
{ omitReportExport: true },
async ({ api }) => {
assert.deepEqual(api, { bundle: "legacy" });
assert.deepEqual(global.__smoke.reports, []);
},
);
await runScenario(
"nightly identity: namespaced setting + context key, status bar indicator",
{
contextPackageJSON: { name: "cline-nightly" },
settings: { "cline-nightly.rollout.bundleOverride": "next" },
},
async ({ api, context }) => {
assert.deepEqual(api, { bundle: "next" });
assert.deepEqual(executedCommands[0], [
"setContext",
"cline-nightly.sdkBundle",
true,
]);
assert.equal(statusBarItems.length, 1);
const [item] = statusBarItems;
assert.equal(item.shown, true);
assert.equal(item.text, "Cline: Next");
assert.match(item.tooltip, /bundleOverride setting/);
assert.ok(
context.subscriptions.includes(item),
"indicator must be disposed with the extension",
);
},
);
await runScenario(
"double failure: both bundles throw, loader reports and rethrows",
{
seed: { "cline.rollout.bundle": "next" },
nextThrows: true,
legacyThrows: true,
telemetryEnabled: true,
expectFailure: true,
expectRefresh: false,
},
async ({ activationError, fetchCalls }) => {
assert.match(String(activationError), /legacy activation exploded/);
assert.deepEqual(
global.__smoke.reports,
[],
"no bundle survived to report the authoritative event",
);
await waitFor(
() => loaderDecisionCaptures(fetchCalls).length >= 2,
"double failure should capture the fallback AND the double_failure events",
);
const captures = loaderDecisionCaptures(fetchCalls);
assert.equal(captures.length, 2);
for (const capture of captures) {
assert.equal(capture.event, "extension.rollout.loader_decision");
assert.equal(capture.properties.fallback, true);
}
const doubleFailure = captures.find(
(c) => c.properties.double_failure === true,
);
assert.ok(doubleFailure, "one capture must be flagged double_failure");
assert.equal(doubleFailure.properties.attempted_bundle, "next");
assert.match(
doubleFailure.properties.error_message,
/legacy activation exploded/,
);
},
);
await runScenario(
"deactivate delegates to active bundle",
{},
async () => {},
async () => {
assert.deepEqual(global.__smoke.deactivated, ["legacy"]);
},
);
console.log("\nall loader smoke scenarios passed");
+180
View File
@@ -0,0 +1,180 @@
/**
* Assemble the combined (loader + next + legacy) VSIX staging directory.
*
* Layout produced:
* <out>/
* extension.js loader bundle (this package's dist/extension.js)
* package.json union manifest (gen-manifest.mjs)
* README.md next's marketplace README
* LICENSE, CHANGELOG.md, assets/, walkthrough/ from next (manifest-referenced, VSIX-root-relative)
* next/ SDK extension payload (dist/, webview-ui/build/, assets/, package.json)
* legacy/ legacy extension payload (dist/, webview-ui/build/, assets/,
* node_modules/@vscode/codicons/dist/, package.json)
*
* Each bundle resolves its own resources under its subdirectory because the
* loader hands it an ExtensionContext whose extensionUri/extensionPath point
* there (see src/scoped-context.ts). Manifest-referenced resources (icons,
* walkthrough media, codicon font declared in contributes.icons) resolve from
* the VSIX root, where the stitcher places next's copies.
*
* Usage:
* node stitch.mjs --next <apps/vscode dir, built> --legacy <apps/vscode dir, built> \
* --loader <dist/extension.js> --version <x.y.z> --out <staging dir>
*/
import {
cpSync,
existsSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import path from "node:path";
import { generateManifest } from "./gen-manifest.mjs";
// Legacy's webview loads codicon.css straight from node_modules (see its
// WebviewProvider); next bundles the font into its webview build but its own
// .vscodeignore still re-includes the codicons dist, so mirror that here.
const BUNDLE_PAYLOAD = {
next: [
"dist",
"webview-ui/build",
"assets",
"package.json",
"node_modules/@vscode/codicons/dist",
],
legacy: [
"dist",
"webview-ui/build",
"assets",
"package.json",
"node_modules/@vscode/codicons/dist",
],
};
/** VSIX-root files, all taken from the next checkout (manifest fields come from next too). */
const ROOT_PAYLOAD = ["LICENSE", "CHANGELOG.md", "assets", "walkthrough"];
function parseArgs(argv) {
const args = {};
for (let i = 2; i < argv.length; i += 2) {
args[argv[i].replace(/^--/, "")] = argv[i + 1];
}
return args;
}
function copyInto(sourceRoot, relPaths, destRoot, { optional = [] } = {}) {
for (const rel of relPaths) {
const source = path.join(sourceRoot, rel);
if (!existsSync(source)) {
if (optional.includes(rel)) {
console.warn(` skip (missing, optional): ${rel}`);
continue;
}
throw new Error(
`required payload missing: ${source} — did the bundle build run?`,
);
}
cpSync(source, path.join(destRoot, rel), {
recursive: true,
dereference: true,
});
console.log(` + ${rel}`);
}
}
export function stitch({ next, legacy, loader, version, out }) {
for (const [name, value] of Object.entries({
next,
legacy,
loader,
version,
out,
})) {
if (!value) {
throw new Error(`--${name} is required`);
}
}
// Refuse to stage from an unbuilt tree early, with a clear message.
for (const [name, root] of [
["next", next],
["legacy", legacy],
]) {
if (!existsSync(path.join(root, "dist", "extension.js"))) {
throw new Error(
`${name} bundle not built: ${root}/dist/extension.js missing`,
);
}
if (
!existsSync(path.join(root, "webview-ui", "build", "assets", "index.js"))
) {
throw new Error(
`${name} webview not built: ${root}/webview-ui/build/assets/index.js missing`,
);
}
}
rmSync(out, { recursive: true, force: true });
mkdirSync(out, { recursive: true });
console.log("root payload (from next):");
copyInto(next, ROOT_PAYLOAD, out, {
optional: ["CHANGELOG.md", "walkthrough"],
});
cpSync(loader, path.join(out, "extension.js"));
console.log(" + extension.js (loader)");
const readme = path.join(next, "README.marketplace.md");
cpSync(
existsSync(readme) ? readme : path.join(next, "README.md"),
path.join(out, "README.md"),
);
console.log(" + README.md");
for (const [bundle, payload] of Object.entries(BUNDLE_PAYLOAD)) {
const sourceRoot = bundle === "next" ? next : legacy;
console.log(`${bundle} payload:`);
copyInto(sourceRoot, payload, path.join(out, bundle), {
optional: ["walkthrough"],
});
}
const manifest = generateManifest(
JSON.parse(readFileSync(path.join(next, "package.json"), "utf8")),
JSON.parse(readFileSync(path.join(legacy, "package.json"), "utf8")),
version,
);
writeFileSync(
path.join(out, "package.json"),
`${JSON.stringify(manifest, null, "\t")}\n`,
);
console.log(" + package.json (union manifest)");
// vsce packages everything in the staging dir; only strip sourcemaps and
// junk. The codicons files under legacy/node_modules must survive, so no
// blanket node_modules ignore here — staging only ever contains what this
// script copied.
writeFileSync(
path.join(out, ".vscodeignore"),
["**/*.map", "**/.DS_Store", ""].join("\n"),
);
console.log(`\nstaged ${out} (version ${version})`);
// Keep the scanner exemption category-scoped to match the standalone bundle
// workflows; see the README for its scope and verification notes.
console.log(
`package it with:\n cd ${out} && vsce package --no-dependencies --allow-package-secrets sendgrid`,
);
}
if (
process.argv[1] &&
import.meta.url === `file://${path.resolve(process.argv[1])}`
) {
try {
stitch(parseArgs(process.argv));
} catch (error) {
console.error(`stitch failed: ${error.message}`);
process.exit(1);
}
}
+134
View File
@@ -0,0 +1,134 @@
import { describe, expect, it } from "bun:test";
import {
bundleContextKey,
decideBundle,
decisionOverrideSource,
idPrefix,
parseRolloutAssignment,
ROLLOUT_FLAG,
settingSection,
} from "./cohort";
const base = {
envOverride: undefined,
settingOverride: undefined,
cached: undefined,
previousFailure: false,
};
describe("decideBundle", () => {
it("defaults to legacy with no cached assignment", () => {
expect(decideBundle(base)).toBe("legacy");
});
it("uses the cached assignment", () => {
expect(decideBundle({ ...base, cached: "next" })).toBe("next");
expect(decideBundle({ ...base, cached: "legacy" })).toBe("legacy");
});
it("treats unknown cached values as legacy", () => {
expect(decideBundle({ ...base, cached: "garbage" })).toBe("legacy");
});
it("a previous activation failure on this version forces legacy", () => {
expect(
decideBundle({ ...base, cached: "next", previousFailure: true }),
).toBe("legacy");
});
it("env override beats everything, including a previous failure", () => {
expect(
decideBundle({ ...base, envOverride: "next", previousFailure: true }),
).toBe("next");
expect(
decideBundle({ ...base, envOverride: "legacy", cached: "next" }),
).toBe("legacy");
});
it("user setting overrides in both directions", () => {
expect(
decideBundle({ ...base, settingOverride: "next", previousFailure: true }),
).toBe("next");
expect(
decideBundle({ ...base, settingOverride: "legacy", cached: "next" }),
).toBe("legacy");
});
it("env override beats the user setting", () => {
expect(
decideBundle({ ...base, envOverride: "legacy", settingOverride: "next" }),
).toBe("legacy");
});
it("ignores invalid and 'auto' overrides", () => {
expect(decideBundle({ ...base, envOverride: "beta", cached: "next" })).toBe(
"next",
);
expect(
decideBundle({ ...base, settingOverride: "auto", cached: "next" }),
).toBe("next");
});
});
describe("decisionOverrideSource", () => {
it("reports which override was active", () => {
expect(decisionOverrideSource(base)).toBeUndefined();
expect(decisionOverrideSource({ ...base, settingOverride: "next" })).toBe(
"setting",
);
expect(
decisionOverrideSource({
...base,
envOverride: "legacy",
settingOverride: "next",
}),
).toBe("env");
expect(
decisionOverrideSource({ ...base, settingOverride: "auto" }),
).toBeUndefined();
});
});
describe("parseRolloutAssignment", () => {
it("promotes only on a literal boolean true", () => {
expect(
parseRolloutAssignment({ featureFlags: { [ROLLOUT_FLAG]: true } }),
).toBe("next");
});
it("is two-way: anything else resolves to legacy (fail-safe)", () => {
// false = dialed out of the cohort; the rest = mis-configured flag.
for (const value of ["test", "control", 1, 0.5, {}, false, undefined]) {
expect(
parseRolloutAssignment({ featureFlags: { [ROLLOUT_FLAG]: value } }),
).toBe("legacy");
}
// Flag deleted / not created yet: nobody promoted.
expect(parseRolloutAssignment({ featureFlags: {} })).toBe("legacy");
});
it("returns undefined for malformed responses (cache left untouched)", () => {
expect(parseRolloutAssignment(undefined)).toBeUndefined();
expect(parseRolloutAssignment({})).toBeUndefined();
expect(parseRolloutAssignment({ featureFlags: null })).toBeUndefined();
});
});
describe("identity prefix", () => {
it("maps the nightly manifest name to the cline-nightly namespace", () => {
expect(idPrefix("cline-nightly")).toBe("cline-nightly");
});
it("maps everything else (stable claude-dev, unknown, missing) to cline", () => {
expect(idPrefix("claude-dev")).toBe("cline");
expect(idPrefix("some-fork")).toBe("cline");
expect(idPrefix(undefined)).toBe("cline");
});
it("derives the setting section and context key from the prefix", () => {
expect(settingSection("cline")).toBe("cline.rollout");
expect(settingSection("cline-nightly")).toBe("cline-nightly.rollout");
expect(bundleContextKey("cline")).toBe("cline.sdkBundle");
expect(bundleContextKey("cline-nightly")).toBe("cline-nightly.sdkBundle");
});
});
+134
View File
@@ -0,0 +1,134 @@
export type Bundle = "next" | "legacy";
/**
* The combined VSIX ships under two identities: the stable extension
* (manifest name "claude-dev", contribution IDs under "cline.*") and the
* nightly (name "cline-nightly", IDs under "cline-nightly.*" — the nightly
* packaging rewrites every `"cline.` prefix in the manifest, see
* scripts/nightlify.mjs and apps/vscode/scripts/publish-nightly.mjs). Anything
* the loader reads from or feeds back into the manifest namespace — the
* bundleOverride setting and the sdkBundle context key — must use the prefix
* matching the installed identity. scripts/gen-manifest.mjs derives the same
* prefix when generating the union manifest; keep them in sync.
*/
export const NIGHTLY_EXTENSION_NAME = "cline-nightly";
export type IdPrefix = "cline" | "cline-nightly";
export function idPrefix(extensionName: string | undefined): IdPrefix {
return extensionName === NIGHTLY_EXTENSION_NAME ? "cline-nightly" : "cline";
}
/** Settings section holding the bundleOverride escape hatch. */
export function settingSection(prefix: IdPrefix): string {
return `${prefix}.rollout`;
}
/** Context key gating per-cohort menus/keybindings in the union manifest. */
export function bundleContextKey(prefix: IdPrefix): string {
return `${prefix}.sdkBundle`;
}
/**
* Loader-owned VS Code memento keys. Never touched by either bundle. These
* deliberately stay un-prefixed by identity: globalState is already scoped to
* the extension ID, so a stable and a nightly install can never collide.
*/
export const COHORT_STATE_KEY = "cline.rollout.bundle";
/** Version of the combined VSIX whose `next` bundle failed to activate, if any. */
export const FAILED_VERSION_STATE_KEY =
"cline.rollout.nextActivationFailedVersion";
/** Epoch ms of the previous loader activation, for launch-cadence telemetry. */
export const LAST_ACTIVATION_STATE_KEY = "cline.rollout.lastActivationAt";
/**
* PostHog rollout flag (created in the Cline PostHog project). Must be a
* plain BOOLEAN release flag with a percentage rollout.
*
* The assignment is TWO-WAY: each background refresh caches exactly what the
* flag says (true => next, anything else => legacy) for the next window, so
* dialing the percentage down moves machines back to legacy on their next
* reload — the single emergency lever is "set the rollout to 0%". Demoted
* machines keep their settings/creds (the state files round-trip), but tasks
* created on the SDK bundle aren't visible in legacy's history until
* re-promoted, and tokens rotated on next may require re-auth on legacy.
*/
export const ROLLOUT_FLAG = "ext-sdk-bundle-rollout";
/** Env var for local dev / e2e to force a bundle. Beats everything. */
export const BUNDLE_OVERRIDE_ENV = "CLINE_BUNDLE_OVERRIDE";
/**
* User-visible escape hatch: `<prefix>.rollout.bundleOverride` in VS Code
* settings ("auto" | "next" | "legacy") — see settingSection() for the
* identity-dependent section name. Editable from settings.json without
* touching mementos, beats the remote flag in either direction, applies on
* window reload. Injected into the union manifest by gen-manifest.mjs — keep
* the schema there in sync with these constants.
*/
export const SETTING_BUNDLE_OVERRIDE = "bundleOverride";
function asBundle(value: unknown): Bundle | undefined {
return value === "next" || value === "legacy" ? value : undefined;
}
export interface CohortInputs {
/** CLINE_BUNDLE_OVERRIDE, if set. */
envOverride: string | undefined;
/** The <prefix>.rollout.bundleOverride user setting ("auto" = no override). */
settingOverride: string | undefined;
/** Cached assignment from the previous window's background flag refresh. */
cached: string | undefined;
/** The next bundle failed to activate on this VSIX version before. */
previousFailure: boolean;
}
/**
* Decide which bundle to activate for this window. Must be synchronous and
* never block on the network: it only consumes state cached by the previous
* window's background refresh, so a percentage change applies on the next
* window reload, mirroring how VS Code's own experiments behave.
*/
export function decideBundle(inputs: CohortInputs): Bundle {
const forced =
asBundle(inputs.envOverride) ?? asBundle(inputs.settingOverride);
if (forced) {
return forced;
}
if (inputs.previousFailure) {
return "legacy";
}
return inputs.cached === "next" ? "next" : "legacy";
}
/** Which override produced the decision, if any — reported on the activation event. */
export function decisionOverrideSource(
inputs: Pick<CohortInputs, "envOverride" | "settingOverride">,
): "env" | "setting" | undefined {
if (asBundle(inputs.envOverride)) {
return "env";
}
if (asBundle(inputs.settingOverride)) {
return "setting";
}
return undefined;
}
/**
* Parse a PostHog /decide (v3) response into the assignment to cache for the
* next window, or undefined when the response is malformed (leave the cached
* assignment untouched — sticky on transient failures).
*
* Deliberately strict so a mis-configured flag fails SAFE toward legacy: only
* boolean `true` promotes. A multivariate variant string, a number, a payload,
* or a missing/deleted flag all resolve to legacy — the flag must stay a plain
* boolean release flag with a percentage rollout.
*/
export function parseRolloutAssignment(response: unknown): Bundle | undefined {
const flags = (
response as { featureFlags?: Record<string, unknown> } | undefined
)?.featureFlags;
if (!flags || typeof flags !== "object") {
return undefined;
}
return flags[ROLLOUT_FLAG] === true ? "next" : "legacy";
}
+259
View File
@@ -0,0 +1,259 @@
import { createRequire } from "node:module";
import path from "node:path";
import * as vscode from "vscode";
import {
BUNDLE_OVERRIDE_ENV,
type Bundle,
bundleContextKey,
COHORT_STATE_KEY,
decideBundle,
decisionOverrideSource,
FAILED_VERSION_STATE_KEY,
type IdPrefix,
idPrefix,
LAST_ACTIVATION_STATE_KEY,
SETTING_BUNDLE_OVERRIDE,
settingSection,
} from "./cohort";
import { refreshCohort, reportLoaderDecision } from "./rollout";
import { scopedContext } from "./scoped-context";
/**
* Cline rollout loader.
*
* The VSIX ships two complete, independently built extension bundles:
* next/ — the SDK-based extension (built from main's apps/vscode)
* legacy/ — the pre-SDK extension (built from the legacy-extension branch)
*
* This entrypoint picks exactly one per window — from state cached by the
* previous window's background flag refresh, never from a blocking network
* call — activates it with a context whose install-root paths point into its
* subdirectory, and delegates everything else to it. If the next bundle throws
* during activation, the loader disposes whatever it half-registered, pins
* this VSIX version back to legacy, and activates legacy instead.
*/
// Resolved at runtime relative to the installed VSIX root; must stay opaque to
// esbuild so the bundles aren't inlined into the loader.
const requireFromVsixRoot = createRequire(__filename);
interface BundleModule {
activate(context: vscode.ExtensionContext): Promise<unknown> | unknown;
deactivate?(): Promise<void> | void;
/**
* Exported by both bundles' entrypoints (see rollout-metadata.ts on each
* branch): captures the AUTHORITATIVE `extension.rollout.bundle_activated`
* event through the bundle's own variant-attributed telemetry pipeline.
* Optional so the loader keeps working against a bundle built before the
* export existed.
*/
reportRolloutActivation?(input: {
attemptedBundle: Bundle;
actualBundle: Bundle;
fallback: boolean;
error?: unknown;
}): Promise<void>;
}
let activeBundle: { module: BundleModule; name: Bundle } | undefined;
interface ActivationMeta {
msSinceLastActivation?: number;
override?: "env" | "setting";
}
/** Set when the original decision crashed and this activation is the fallback. */
interface FallbackFrom {
attempted: Bundle;
error: unknown;
}
export async function activate(context: vscode.ExtensionContext) {
const loaderVersion: string =
context.extension.packageJSON?.version ?? "unknown";
const prefix = idPrefix(context.extension.packageJSON?.name);
// Launch-cadence telemetry: how stale the previous activation is bounds how
// fast a percentage change can actually reach users' windows.
const lastActivationAt = context.globalState.get<number>(
LAST_ACTIVATION_STATE_KEY,
);
const now = Date.now();
void context.globalState.update(LAST_ACTIVATION_STATE_KEY, now);
const overrides = {
envOverride: process.env[BUNDLE_OVERRIDE_ENV],
settingOverride: vscode.workspace
.getConfiguration(settingSection(prefix))
.get<string>(SETTING_BUNDLE_OVERRIDE),
};
const bundle = decideBundle({
...overrides,
cached: context.globalState.get<string>(COHORT_STATE_KEY),
previousFailure:
context.globalState.get<string>(FAILED_VERSION_STATE_KEY) ===
loaderVersion,
});
const meta: ActivationMeta = {
msSinceLastActivation:
typeof lastActivationAt === "number" && lastActivationAt <= now
? now - lastActivationAt
: undefined,
override: decisionOverrideSource(overrides),
};
return activateBundle(context, prefix, bundle, loaderVersion, meta, true);
}
async function activateBundle(
context: vscode.ExtensionContext,
prefix: IdPrefix,
bundle: Bundle,
loaderVersion: string,
meta: ActivationMeta,
refreshAssignmentOnSuccess: boolean,
fallbackFrom?: FallbackFrom,
): Promise<unknown> {
// Menus/keybindings gated per cohort in package.json key off this.
await vscode.commands.executeCommand(
"setContext",
bundleContextKey(prefix),
bundle === "next",
);
const subscriptionsBefore = context.subscriptions.length;
try {
const module = requireFromVsixRoot(
path.join(__dirname, bundle, "dist", "extension.js"),
) as BundleModule;
const api = await module.activate(scopedContext(context, bundle));
activeBundle = { module, name: bundle };
// Cache the next window's assignment only after the originally selected
// bundle activates. A crash fallback must not start a refresh that could
// promote the cohort back to next after the handler pins it to legacy.
if (refreshAssignmentOnSuccess) {
void refreshCohort(context).catch(() => {});
}
// Authoritative activation event, captured by the bundle's own telemetry
// (built with CLINE_ROLLOUT_VARIANT). On fallback this runs in the legacy
// bundle — next's pipeline is the thing that just crashed.
if (typeof module.reportRolloutActivation === "function") {
void module
.reportRolloutActivation({
attemptedBundle: fallbackFrom?.attempted ?? bundle,
actualBundle: bundle,
fallback: fallbackFrom !== undefined,
error: fallbackFrom?.error,
})
.catch(() => {});
}
// The loader's own decision event fires once per window: the fallback
// path already reported (fallback: true) from the catch block below.
if (!fallbackFrom) {
void reportLoaderDecision(context, bundle, {
...meta,
fallback: false,
}).catch(() => {});
}
showNightlyBundleIndicator(context, prefix, bundle, meta, fallbackFrom);
return api;
} catch (error) {
if (bundle === "legacy") {
// Nothing left to fall back to; let VS Code surface the failure. When
// this was already the crash fallback, no bundle telemetry pipeline is
// alive — the loader's direct event is the only record.
void reportLoaderDecision(context, "legacy", {
...meta,
attemptedBundle: fallbackFrom?.attempted ?? "legacy",
fallback: fallbackFrom !== undefined,
doubleFailure: fallbackFrom !== undefined,
errorMessage: formatActivationError(error),
}).catch(() => {});
throw error;
}
console.error(
"[cline-rollout] next bundle failed to activate, falling back to legacy:",
error,
);
disposeSubscriptionsAddedAfter(context, subscriptionsBefore);
// Pin this VSIX version to legacy so we don't crash-loop every window.
// A new release (new version string) gets to try next again.
await context.globalState.update(FAILED_VERSION_STATE_KEY, loaderVersion);
await context.globalState.update(COHORT_STATE_KEY, "legacy");
void reportLoaderDecision(context, "legacy", {
...meta,
attemptedBundle: "next",
fallback: true,
errorMessage: formatActivationError(error),
}).catch(() => {});
return activateBundle(
context,
prefix,
"legacy",
loaderVersion,
meta,
false,
{ attempted: "next", error },
);
}
}
function formatActivationError(error: unknown): string {
return error instanceof Error
? `${error.message}\n${error.stack ?? ""}`.slice(0, 2000)
: String(error);
}
/**
* Nightly-only visible indicator of which bundle this window is running.
* The stable combined VSIX (and any ordinary build) never shows it: the
* prefix is derived from the packaged manifest name. Best-effort — the
* indicator must never take down an otherwise successful activation.
*/
function showNightlyBundleIndicator(
context: vscode.ExtensionContext,
prefix: IdPrefix,
bundle: Bundle,
meta: ActivationMeta,
fallbackFrom: FallbackFrom | undefined,
) {
if (prefix !== "cline-nightly") {
return;
}
try {
const item = vscode.window.createStatusBarItem(
vscode.StatusBarAlignment.Right,
-1000,
);
item.text = bundle === "next" ? "Cline: Next" : "Cline: Legacy";
const detail = fallbackFrom
? "crash fallback from the next bundle"
: meta.override
? `forced by ${meta.override === "env" ? `the ${BUNDLE_OVERRIDE_ENV} env var` : "the bundleOverride setting"}`
: "rollout assignment";
item.tooltip = `Cline nightly A/B rollout: running the ${bundle === "next" ? "next (SDK)" : "legacy"} bundle (${detail}).`;
item.show();
context.subscriptions.push(item);
} catch (error) {
console.warn("[cline-rollout] could not show bundle indicator:", error);
}
}
/** Dispose anything a failed activation managed to register before it threw. */
function disposeSubscriptionsAddedAfter(
context: vscode.ExtensionContext,
startIndex: number,
) {
const added = context.subscriptions.splice(startIndex);
for (const disposable of added) {
try {
disposable.dispose();
} catch {
// best effort — a broken disposable must not block the fallback
}
}
}
export async function deactivate() {
return activeBundle?.module.deactivate?.();
}
+223
View File
@@ -0,0 +1,223 @@
import { readFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { machineId } from "node-machine-id";
import * as vscode from "vscode";
import {
type Bundle,
COHORT_STATE_KEY,
parseRolloutAssignment,
ROLLOUT_FLAG,
} from "./cohort";
/**
* Same PostHog project + reverse proxy the extension's telemetry uses.
* The API key is injected at build time by CI (see esbuild.mjs), matching how
* apps/vscode injects TELEMETRY_SERVICE_API_KEY. Local builds without the key
* skip all network calls, so the loader defaults everyone to legacy.
*/
const POSTHOG_HOST = "https://data.cline.bot";
const POSTHOG_API_KEY = process.env.TELEMETRY_SERVICE_API_KEY;
const FETCH_TIMEOUT_MS = 10_000;
const FEATURE_FLAG_CALLED_EVENT = "$feature_flag_called";
/**
* Mirror the distinct-id derivation in apps/vscode
* (src/services/logging/distinctId.ts) so PostHog evaluates the rollout flag
* against the same id the bundles report telemetry with — otherwise cohort
* membership can't be correlated with cohort behavior in dashboards.
* Falls back to vscode.env.machineId rather than generating + persisting a new
* id: the loader must never write to the shared ~/.cline state files.
*/
async function getDistinctId(): Promise<string> {
const generated = await readSharedGlobalStateKey("cline.generatedMachineId");
if (typeof generated === "string" && generated.length > 0) {
return generated;
}
try {
const id = await machineId();
if (id) {
return id;
}
} catch {
// fall through
}
return vscode.env.machineId;
}
/** Read one key from the file-backed global state both bundles share. */
async function readSharedGlobalStateKey(key: string): Promise<unknown> {
try {
const clineDir = process.env.CLINE_DIR || path.join(os.homedir(), ".cline");
const raw = await readFile(
path.join(clineDir, "data", "globalState.json"),
"utf8",
);
const state = JSON.parse(raw);
return state?.[key];
} catch {
return undefined;
}
}
async function postJson(
url: string,
body: object,
): Promise<Response | undefined> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
return await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: controller.signal,
});
} catch {
return undefined;
} finally {
clearTimeout(timer);
}
}
async function fetchAssignment(
distinctId: string,
): Promise<Bundle | undefined> {
if (!POSTHOG_API_KEY) {
return undefined;
}
const response = await postJson(`${POSTHOG_HOST}/decide?v=3`, {
api_key: POSTHOG_API_KEY,
distinct_id: distinctId,
});
if (!response?.ok) {
return undefined;
}
try {
const decideResponse = await response.json();
const assignment = parseRolloutAssignment(decideResponse);
if (!assignment) {
return undefined;
}
// Mirror FeatureFlagsService/PostHog SDK exposure tracking for this
// loader-owned flag evaluation. This event is intentionally not gated by
// telemetry opt-out: feature-flag evaluation remains enabled so PostHog can
// correctly attribute rollout cohorts, while loader_decision below still
// respects user/host telemetry settings.
void reportFeatureFlagCalled(
distinctId,
getRolloutFlagResponse(decideResponse),
).catch(() => {});
return assignment;
} catch {
return undefined;
}
}
function getRolloutFlagResponse(response: unknown): unknown {
const flags = (
response as { featureFlags?: Record<string, unknown> } | undefined
)?.featureFlags;
return flags && typeof flags === "object" ? flags[ROLLOUT_FLAG] : undefined;
}
async function reportFeatureFlagCalled(
distinctId: string,
flagResponse: unknown,
): Promise<void> {
if (!POSTHOG_API_KEY) {
return;
}
await postJson(`${POSTHOG_HOST}/capture/`, {
api_key: POSTHOG_API_KEY,
event: FEATURE_FLAG_CALLED_EVENT,
distinct_id: distinctId,
properties: {
$feature_flag: ROLLOUT_FLAG,
$feature_flag_response: flagResponse,
},
});
}
/**
* Background refresh: evaluate the rollout flag and cache exactly what it
* says for the NEXT window (two-way: dialing the percentage down demotes on
* the next reload). Never affects the bundle already activated in this
* window, and failures leave the cached assignment untouched (sticky on
* transient errors only).
*/
export async function refreshCohort(
context: vscode.ExtensionContext,
): Promise<void> {
const distinctId = await getDistinctId();
const assignment = await fetchAssignment(distinctId);
if (!assignment) {
return;
}
await context.globalState.update(COHORT_STATE_KEY, assignment);
}
/**
* The loader's own decision event. Distinct from the AUTHORITATIVE
* `extension.rollout.bundle_activated` event, which the activated bundle
* itself captures through its variant-attributed telemetry pipeline (the
* loader triggers it via the bundle's reportRolloutActivation export — see
* src/extension.ts). This event carries the loader-side metadata that event
* can't (override source, launch cadence, loader version) and is the only
* signal left when BOTH bundles fail to activate.
*/
export const LOADER_DECISION_EVENT = "extension.rollout.loader_decision";
/**
* Report the loader's bundle decision (and whether it was a crash fallback).
* Feature-flag evaluation is always allowed (matching the extension's
* FeatureFlagsService), but event capture respects the user's telemetry
* opt-out and VS Code's global telemetry setting.
*/
export async function reportLoaderDecision(
context: vscode.ExtensionContext,
bundle: Bundle,
options: {
fallback: boolean;
/** Bundle the loader originally decided on; differs from `bundle` on fallback. */
attemptedBundle?: Bundle;
/** Both bundles threw — nothing activated, and no bundle telemetry exists. */
doubleFailure?: boolean;
errorMessage?: string;
/** Time since the previous loader activation on this machine, if known. */
msSinceLastActivation?: number;
/** Whether an env var or user setting forced this bundle. */
override?: "env" | "setting";
},
): Promise<void> {
if (!POSTHOG_API_KEY) {
return;
}
const telemetrySetting = await readSharedGlobalStateKey("telemetrySetting");
if (telemetrySetting === "disabled" || !vscode.env.isTelemetryEnabled) {
return;
}
const distinctId = await getDistinctId();
await postJson(`${POSTHOG_HOST}/capture/`, {
api_key: POSTHOG_API_KEY,
event: LOADER_DECISION_EVENT,
distinct_id: distinctId,
properties: {
bundle,
attempted_bundle: options.attemptedBundle ?? bundle,
fallback: options.fallback,
double_failure: options.doubleFailure,
error_message: options.errorMessage,
// Launch-cadence distribution: how long promotions take to reach real
// windows tells us how fast the rollout percentage can safely be dialed.
ms_since_last_activation: options.msSinceLastActivation,
override: options.override,
loader_version: context.extension.packageJSON?.version,
// Separates nightly traffic from the (future) stable combined VSIX.
extension_name: context.extension.packageJSON?.name,
vscode_version: vscode.version,
},
});
}
+56
View File
@@ -0,0 +1,56 @@
import path from "node:path";
import * as vscode from "vscode";
import type { Bundle } from "./cohort";
/**
* Wrap the real ExtensionContext so a bundle living under `<vsix root>/<sub>/`
* resolves extension-root-relative resources (webview-ui build, walkthrough
* assets, bundled codicons, ...) from its own subtree, without either codebase
* knowing it was relocated.
*
* Only install-root properties are redirected. Storage-related properties
* (globalState, workspaceState, secrets, globalStorageUri, storageUri, logUri)
* intentionally pass through untouched: both bundles must keep sharing the
* exact storage the standalone extension used, so user state survives cohort
* changes and VSIX upgrades.
*/
export function scopedContext(
context: vscode.ExtensionContext,
sub: Bundle,
): vscode.ExtensionContext {
const extensionUri = vscode.Uri.joinPath(context.extensionUri, sub);
const extensionPath = extensionUri.fsPath;
const scopedExtension = new Proxy(context.extension, {
get(target, prop, _receiver) {
if (prop === "extensionUri") {
return extensionUri;
}
if (prop === "extensionPath") {
return extensionPath;
}
const value = Reflect.get(target, prop, target);
return typeof value === "function" ? value.bind(target) : value;
},
});
const overrides = new Map<PropertyKey, unknown>([
["extensionUri", extensionUri],
["extensionPath", extensionPath],
[
"asAbsolutePath",
(relativePath: string) => path.join(extensionPath, relativePath),
],
["extension", scopedExtension],
]);
return new Proxy(context, {
get(target, prop, _receiver) {
if (overrides.has(prop)) {
return overrides.get(prop);
}
const value = Reflect.get(target, prop, target);
return typeof value === "function" ? value.bind(target) : value;
},
}) as vscode.ExtensionContext;
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"lib": ["ES2022"],
"types": ["node", "vscode"],
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}
+46 -2
View File
@@ -63,7 +63,7 @@ service ModelsService {
rpc readProviderConfig(StringRequest) returns (ProviderConfigResponse);
// Writes provider configuration fields and returns redacted effective configuration
rpc writeProviderConfig(WriteProviderConfigRequest) returns (ProviderConfigResponse);
// Commits a mode-specific model selection atomically with its model metadata
// Commits a mode-specific model ID with optional user-authored metadata overrides
rpc commitModelSelection(CommitModelSelectionRequest) returns (Empty);
}
@@ -122,6 +122,36 @@ message OpenRouterModelInfo {
optional ApiFormat api_format = 16;
}
// User-authored per-model metadata stored in models.json.
//
// Semantics:
// - `capabilities` accepts only the SDK ModelCapability values (e.g.
// "images", "tools", "prompt-cache", "reasoning", "files"); unknown
// strings are silently dropped by the host. The array is additive over
// the base metadata; the explicit supports_* booleans win when both are
// present.
// - `is_r1_format_required` is a legacy alias that forces the R1 chat
// format only when true; `api_format` is canonical.
// - Invalid numbers (non-positive token limits, negative prices or
// temperature, non-finite values) are silently discarded, not rejected.
message ModelOverrides {
optional string name = 1;
optional int64 max_tokens = 2;
optional int64 context_window = 3;
optional int64 max_input_tokens = 4;
repeated string capabilities = 5;
optional bool supports_vision = 6;
optional bool supports_attachments = 7;
optional bool supports_reasoning = 8;
optional double input_price = 9;
optional double output_price = 10;
optional double cache_reads_price = 11;
optional double cache_writes_price = 12;
optional double temperature = 13;
optional ApiFormat api_format = 14;
optional bool is_r1_format_required = 15;
}
// Shared response message for model information
message OpenRouterCompatibleModelInfo {
map<string, OpenRouterModelInfo> models = 1;
@@ -222,12 +252,16 @@ message ProviderConfigResponse {
optional CommittedModelSelection act_selection = 11;
optional AwsProviderConfig aws = 12;
optional GcpProviderConfig gcp = 13;
// Provider-level context window (providers.json `contextWindow`). Used by
// bring-your-own-model providers (e.g. Ollama, where it maps to num_ctx).
optional int32 context_window = 14;
}
message CommittedModelSelection {
string provider_id = 1;
string model_id = 2;
OpenRouterModelInfo model_info = 3;
optional ModelOverrides overrides = 4;
}
message ProviderReasoningPatch {
@@ -249,6 +283,8 @@ message WriteProviderConfigPatch {
optional bool clear_headers = 10;
optional AwsProviderConfig aws = 11;
optional GcpProviderConfig gcp = 12;
// Provider-level context window; 0 clears the setting.
optional int32 context_window = 13;
}
message WriteProviderConfigRequest {
@@ -257,10 +293,18 @@ message WriteProviderConfigRequest {
}
message CommitModelSelectionRequest {
// Field 4 carried `OpenRouterModelInfo model_info` in earlier releases.
// Reusing the number with a different message type mis-decodes on version
// skew, so the retired field stays reserved.
reserved 4;
reserved "model_info";
string provider_id = 1;
string mode = 2;
string model_id = 3;
OpenRouterModelInfo model_info = 4;
// Tri-state: ABSENT leaves the model's stored overrides unchanged, an
// explicitly EMPTY message clears them, and a populated message replaces
// them wholesale (no per-field merge).
optional ModelOverrides overrides = 5;
}
message ClineRecommendedModel {
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"
import type { EffectiveProviderConfig, ProviderCatalog, ProviderConfigStore } from "@/sdk/model-catalog/contracts"
import { computeConfigFingerprint } from "@/sdk/model-catalog/fingerprint"
import { parseProviderId } from "@/sdk/model-catalog/provider-id"
import { ApiFormat, OpenRouterModelInfo } from "@/shared/proto/cline/models"
import { ApiFormat, ModelOverrides } from "@/shared/proto/cline/models"
import type { ProviderCatalogController } from "../providerCatalogShared"
type TestStateManager = {
@@ -153,6 +153,22 @@ describe("provider model catalog handlers", () => {
baseUrl: "https://api.example.com/v1",
auth: { accessToken: "SECRET_SENTINEL_ACCESS", refreshToken: "SECRET_SENTINEL_REFRESH", accountId: "acct-1" },
})
vi.mocked(store.readSelection).mockImplementation((_providerId, mode) =>
mode === "act"
? {
providerId,
modelId: "custom-model",
overrides: {
capabilities: ["tools", "custom-capability"],
inputPrice: 1.25,
supportsVision: false,
temperature: 0.3,
apiFormat: ApiFormat.OPENAI_RESPONSES,
},
modelInfo: { name: "Custom model", contextWindow: 64_000, supportsPromptCache: false },
}
: undefined,
)
const controller = makeController(store, makeCatalog())
const response = await readProviderConfig(controller, { value: "cline" })
@@ -165,10 +181,24 @@ describe("provider model catalog handlers", () => {
hasRefreshToken: true,
accountId: "acct-1",
})
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL")
expect(response.actSelection).toMatchObject({
providerId: "cline",
modelId: "custom-model",
modelInfo: { name: "Custom model", contextWindow: 64_000 },
overrides: {
capabilities: ["tools", "custom-capability"],
inputPrice: 1.25,
supportsVision: false,
temperature: 0.3,
apiFormat: ApiFormat.OPENAI_RESPONSES,
},
})
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL_API_KEY")
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL_ACCESS")
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL_REFRESH")
})
it("writeProviderConfig writes a patch and returns redacted updated config", async () => {
it("writeProviderConfig writes a patch and returns a redacted response", async () => {
const { writeProviderConfig } = await import("../writeProviderConfig")
const providerId = parseProviderId("ollama")
const updatedConfig: EffectiveProviderConfig = {
@@ -188,8 +218,10 @@ describe("provider model catalog handlers", () => {
apiKey: "SECRET_SENTINEL_OLLAMA",
baseUrl: "http://localhost:11434/v1",
})
expect(response.apiKeyLength).toBe("SECRET_SENTINEL_OLLAMA".length)
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL")
expect(response).toMatchObject({
apiKeyLength: "SECRET_SENTINEL_OLLAMA".length,
})
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL_OLLAMA")
})
it("writeProviderConfig can explicitly clear headers", async () => {
@@ -210,7 +242,7 @@ describe("provider model catalog handlers", () => {
expect(store.write).toHaveBeenCalledWith(providerId, { headers: {} })
})
it("commitModelSelection validates mode and commits the full selection envelope", async () => {
it("commitModelSelection validates mode and commits model settings", async () => {
const { commitModelSelection } = await import("../commitModelSelection")
const providerId = parseProviderId("deepseek")
const store = makeStore({ providerId })
@@ -224,22 +256,20 @@ describe("provider model catalog handlers", () => {
providerId: "deepseek",
mode: "act",
modelId: "deepseek-v4-flash",
modelInfo: OpenRouterModelInfo.create({
overrides: ModelOverrides.create({
name: "DeepSeek V4 Flash",
contextWindow: 456,
supportsPromptCache: true,
apiFormat: ApiFormat.OPENAI_CHAT,
capabilities: ["prompt-cache"],
}),
})
expect(store.commitSelection).toHaveBeenCalledWith(providerId, "act", {
providerId,
modelId: "deepseek-v4-flash",
modelInfo: expect.objectContaining({
overrides: expect.objectContaining({
name: "DeepSeek V4 Flash",
contextWindow: 456,
supportsPromptCache: true,
apiFormat: ApiFormat.OPENAI_CHAT,
capabilities: ["prompt-cache"],
}),
})
expect(stateManager.setGlobalStateBatch).toHaveBeenCalledWith({
@@ -249,6 +279,49 @@ describe("provider model catalog handlers", () => {
expect(stateManager.flushPendingState).toHaveBeenCalledTimes(1)
})
// The overrides field is tri-state: absent preserves the model's stored
// overrides, an explicitly empty message clears them, and a populated
// message replaces them. The two boundary cases are pinned here because
// the webview relies on both (see useProviderConfig.test.ts).
it("commitModelSelection maps an ABSENT overrides field to undefined (preserve stored overrides)", async () => {
const { commitModelSelection } = await import("../commitModelSelection")
const providerId = parseProviderId("deepseek")
const store = makeStore({ providerId })
const controller = makeController(store, makeCatalog())
await commitModelSelection(controller, {
providerId: "deepseek",
mode: "act",
modelId: "deepseek-v4-flash",
})
expect(store.commitSelection).toHaveBeenCalledWith(providerId, "act", {
providerId,
modelId: "deepseek-v4-flash",
overrides: undefined,
})
})
it("commitModelSelection maps an EMPTY overrides message to an empty object (clear stored overrides)", async () => {
const { commitModelSelection } = await import("../commitModelSelection")
const providerId = parseProviderId("deepseek")
const store = makeStore({ providerId })
const controller = makeController(store, makeCatalog())
await commitModelSelection(controller, {
providerId: "deepseek",
mode: "act",
modelId: "deepseek-v4-flash",
overrides: ModelOverrides.create({}),
})
expect(store.commitSelection).toHaveBeenCalledWith(providerId, "act", {
providerId,
modelId: "deepseek-v4-flash",
overrides: {},
})
})
it("commitModelSelection reports provider changes when config is initialized", async () => {
const { commitModelSelection } = await import("../commitModelSelection")
const providerId = parseProviderId("deepseek")
@@ -268,10 +341,7 @@ describe("provider model catalog handlers", () => {
providerId: "deepseek",
mode: "act",
modelId: "deepseek-v4-flash",
modelInfo: OpenRouterModelInfo.create({
name: "DeepSeek V4 Flash",
apiFormat: ApiFormat.OPENAI_CHAT,
}),
overrides: ModelOverrides.create({ name: "DeepSeek V4 Flash" }),
})
expect(handleApiConfigurationChanged).toHaveBeenCalledWith({}, { actModeApiProvider: "deepseek" })
@@ -289,7 +359,7 @@ describe("provider model catalog handlers", () => {
providerId: "deepseek",
mode: "invalid",
modelId: "deepseek-v4-flash",
modelInfo: OpenRouterModelInfo.create({ supportsPromptCache: true }),
overrides: ModelOverrides.create({ capabilities: ["prompt-cache"] }),
}),
).rejects.toThrow('mode must be "plan" or "act"')
expect(store.commitSelection).not.toHaveBeenCalled()
@@ -75,7 +75,6 @@ describe("provider model catalog backend smoke", () => {
providerId: "deepseek",
mode: "act",
modelId,
modelInfo,
}),
)
@@ -3,12 +3,14 @@ import type {
EffectiveProviderConfig,
Mode,
ModelSelection,
ModelSelectionOverrides,
ProviderCatalog,
ProviderConfigPatch,
ProviderConfigStore,
ProviderId,
ProviderListing,
ProviderModelsResult,
ResolvedModelSelection,
} from "@/sdk/model-catalog/contracts"
import { parseProviderId } from "@/sdk/model-catalog/provider-id"
import {
@@ -17,13 +19,15 @@ import {
CommitModelSelectionRequest,
CommittedModelSelection,
GcpProviderConfig,
ModelOverrides as ModelOverridesProto,
OpenRouterModelInfo,
ProviderConfigResponse,
ProviderListing as ProviderListingProto,
ProviderModelsResponse,
WriteProviderConfigPatch,
} from "@/shared/proto/cline/models"
import { fromProtobufModelInfo, toProtobufModelInfo } from "@/shared/proto-conversions/models/typeConversion"
import { fromProtobufModelOverrides, toProtobufModelOverrides } from "@/shared/proto-conversions/models/modelOverrides"
import { toProtobufModelInfo } from "@/shared/proto-conversions/models/typeConversion"
import type { GlobalStateAndSettings } from "@/shared/storage/state-keys"
export interface ProviderCatalogController {
@@ -94,7 +98,11 @@ function toProtobufModels(models: ReadonlyMap<string, ModelInfo>): Record<string
return result
}
function toCommittedModelSelectionProto(selection: ModelSelection | undefined): CommittedModelSelection | undefined {
function toModelOverridesProto(overrides: ModelSelectionOverrides | undefined): ModelOverridesProto | undefined {
return overrides ? toProtobufModelOverrides(overrides) : undefined
}
function toCommittedModelSelectionProto(selection: ResolvedModelSelection | undefined): CommittedModelSelection | undefined {
if (!selection) {
return undefined
}
@@ -102,6 +110,7 @@ function toCommittedModelSelectionProto(selection: ModelSelection | undefined):
providerId: selection.providerId,
modelId: selection.modelId,
modelInfo: toProtobufModelInfo(selection.modelInfo),
overrides: toModelOverridesProto(selection.overrides),
})
}
@@ -199,6 +208,7 @@ export function toRedactedProviderConfigResponse(
actSelection: toCommittedModelSelectionProto(store?.readSelection(config.providerId, "act")),
aws: toRedactedAwsProviderConfigProto(config.aws),
gcp: toRedactedGcpProviderConfigProto(config.gcp),
contextWindow: config.contextWindow,
})
}
@@ -218,6 +228,10 @@ export function toProviderConfigPatch(protoPatch: WriteProviderConfigPatch | und
...(protoPatch.apiLine !== undefined ? { apiLine: protoPatch.apiLine } : {}),
...(protoPatch.aws !== undefined ? { aws: toAwsProviderConfigPatch(protoPatch) } : {}),
...(protoPatch.gcp !== undefined ? { gcp: toGcpProviderConfigPatch(protoPatch) } : {}),
// A zero context window over the wire means "clear the setting".
...(protoPatch.contextWindow !== undefined
? { contextWindow: protoPatch.contextWindow > 0 ? protoPatch.contextWindow : null }
: {}),
...(protoPatch.accessToken !== undefined || protoPatch.refreshToken !== undefined || protoPatch.accountId !== undefined
? {
auth: {
@@ -241,17 +255,18 @@ export function toProviderConfigPatch(protoPatch: WriteProviderConfigPatch | und
}
}
function toSelectionOverrides(overrides: ModelOverridesProto | undefined): ModelSelectionOverrides | undefined {
return fromProtobufModelOverrides(overrides)
}
export function toModelSelection(request: CommitModelSelectionRequest, providerId: ProviderId): ModelSelection {
const modelId = request.modelId.trim()
if (!modelId) {
throw new Error("model_id is required")
}
if (!request.modelInfo) {
throw new Error("model_info is required")
}
return {
providerId,
modelId,
modelInfo: fromProtobufModelInfo(request.modelInfo),
overrides: toSelectionOverrides(request.overrides),
}
}
@@ -12,10 +12,11 @@ import { type ProviderCatalogController, parseProviderIdRequest } from "./provid
* Resolution order:
*
* 1. Committed selection — the user's most-recently-chosen plan/act
* selection in the provider config store. This is the source of
* truth for dynamic-list providers (openrouter, openai-compatible,
* ollama, lmstudio, requesty, litellm, …) where the picker writes
* the live `ModelInfo` into the selection when the user commits.
* model ID resolved against SDK catalog metadata, the picker's state
* snapshot, and stored overrides by the provider config store. A
* selection whose metadata is pure fallback fabrication (no catalog or
* state base, no overrides) is deferred behind the catalog steps below
* and only returned as a last resort.
*
* 2. Catalog peek — a non-fetching look-up of the catalog cache for
* the provider's current effective config fingerprint. Hits when
@@ -41,23 +42,24 @@ export async function resolveModelInfo(
const requestedModelId = request.modelId?.trim() || ""
const store = controller.getProviderConfigStore()
// A committed selection whose metadata is pure fallback fabrication (no
// catalog/state base and no user overrides) must not shadow the live
// catalog below; it is kept only as a last resort before "unknown".
let fallbackSelection: ReturnType<typeof store.readSelection>
if (requestedModelId) {
const actSelection = store.readSelection(providerId, "act")
if (actSelection?.modelId === requestedModelId) {
for (const mode of ["act", "plan"] as const) {
const selection = store.readSelection(providerId, mode)
if (selection?.modelId !== requestedModelId) {
continue
}
if (selection.modelInfoSource === "fallback" && !selection.overrides) {
fallbackSelection ??= selection
continue
}
return ResolveModelInfoResponse.create({
providerId,
modelId: actSelection.modelId,
modelInfo: toProtobufModelInfo(actSelection.modelInfo),
source: "committed-selection",
})
}
const planSelection = store.readSelection(providerId, "plan")
if (planSelection?.modelId === requestedModelId) {
return ResolveModelInfoResponse.create({
providerId,
modelId: planSelection.modelId,
modelInfo: toProtobufModelInfo(planSelection.modelInfo),
modelId: selection.modelId,
modelInfo: toProtobufModelInfo(selection.modelInfo),
source: "committed-selection",
})
}
@@ -74,7 +76,9 @@ export async function resolveModelInfo(
const cached = catalog.peekModels(providerId)
if (cached?.ok) {
const hit = pickFromCatalog(cached, requestedModelId, allowCustomModelIds)
if (hit) {
// A default-model substitution answers a question about a different
// model; the committed selection, even fallback-grade, is closer.
if (hit && (hit.matchedRequested || !fallbackSelection)) {
return ResolveModelInfoResponse.create({
providerId,
modelId: hit.modelId,
@@ -90,7 +94,7 @@ export async function resolveModelInfo(
const resolved = await catalog.resolveModels(providerId).catch(() => undefined)
if (resolved?.ok) {
const hit = pickFromCatalog(resolved, requestedModelId, allowCustomModelIds)
if (hit) {
if (hit && (hit.matchedRequested || !fallbackSelection)) {
return ResolveModelInfoResponse.create({
providerId,
modelId: hit.modelId,
@@ -100,6 +104,15 @@ export async function resolveModelInfo(
}
}
if (fallbackSelection) {
return ResolveModelInfoResponse.create({
providerId,
modelId: fallbackSelection.modelId,
modelInfo: toProtobufModelInfo(fallbackSelection.modelInfo),
source: "committed-selection",
})
}
return ResolveModelInfoResponse.create({
providerId,
modelId: requestedModelId,
@@ -2,6 +2,9 @@ import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import type { CoreSessionConfig } from "@cline/core"
import * as LlmsModels from "@cline/llms"
import { ApiFormat } from "@shared/proto/cline/models"
import { Logger } from "@shared/services/Logger"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import {
buildResumeSessionInput,
@@ -15,9 +18,12 @@ import {
resolveApiKey,
updateHistoryItem,
} from "./cline-session-factory"
import { parseProviderId } from "./model-catalog/provider-id"
import { createProviderConfigStore } from "./model-catalog/store"
const mocks = vi.hoisted(() => {
const providerSettingsManager = {
getFilePath: vi.fn(() => path.join(tempDir, "settings", "providers.json")),
getLastUsedProviderSettings: vi.fn(() => undefined),
getProviderSettings: vi.fn((_providerId?: string) => undefined),
saveProviderSettings: vi.fn(),
@@ -39,6 +45,9 @@ const mocks = vi.hoisted(() => {
}
return undefined
}),
setGlobalStateBatch: vi.fn(),
setGlobalState: vi.fn(),
setSecret: vi.fn(),
},
}
})
@@ -72,11 +81,14 @@ vi.mock("@shared/services/Logger", () => ({
let tempDir: string
const previousGlobalSettingsPath = process.env.CLINE_GLOBAL_SETTINGS_PATH
const previousDataDir = process.env.CLINE_DATA_DIR
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "cline-session-factory-"))
process.env.CLINE_DATA_DIR = tempDir
process.env.CLINE_GLOBAL_SETTINGS_PATH = path.join(tempDir, "global-settings.json")
vi.clearAllMocks()
LlmsModels.resetRegistry()
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "anthropic",
actModeApiModelId: "claude-sonnet-4-6",
@@ -88,12 +100,14 @@ beforeEach(() => {
}
return undefined
})
mocks.providerSettingsManager.getFilePath.mockReturnValue(path.join(tempDir, "settings", "providers.json"))
mocks.providerSettingsManager.getLastUsedProviderSettings.mockReturnValue(undefined)
mocks.providerSettingsManager.getProviderSettings.mockReturnValue(undefined)
})
afterEach(() => {
process.env.CLINE_GLOBAL_SETTINGS_PATH = previousGlobalSettingsPath
process.env.CLINE_DATA_DIR = previousDataDir
fs.rmSync(tempDir, { recursive: true, force: true })
})
@@ -135,6 +149,11 @@ describe("getDefaultModelIdForProvider", () => {
expect(getDefaultModelIdForProvider("unknown-provider")).toBeUndefined()
})
it("returns no default for local-model-source providers so a cloud-catalog model is never silently selected", () => {
expect(getDefaultModelIdForProvider("ollama")).toBeUndefined()
expect(getDefaultModelIdForProvider("lmstudio")).toBeUndefined()
})
it("resolves the OpenAI Compatible default through the extension's openai alias", () => {
// The extension stores the OpenAI Compatible provider as "openai" while
// the SDK catalog keys it as "openai-compatible". toSdkProviderId bridges
@@ -230,9 +249,11 @@ describe("normalizeSdkBaseUrl", () => {
expect(normalizeSdkBaseUrl("openai-compatible", " ")).toBeUndefined()
})
it("uses provider catalog defaults to add the SDK endpoint path when the user supplies only an origin", () => {
expect(normalizeSdkBaseUrl("ollama", "http://localhost:11434")).toBe("http://localhost:11434/v1")
expect(normalizeSdkBaseUrl("ollama", "http://localhost:11434/")).toBe("http://localhost:11434/v1")
it("passes Ollama origins through unchanged (the native-API vendor appends /api itself)", () => {
expect(normalizeSdkBaseUrl("ollama", "http://localhost:11434")).toBe("http://localhost:11434")
expect(normalizeSdkBaseUrl("ollama", "http://localhost:11434/")).toBe("http://localhost:11434/")
// Legacy 4.0.x configs may carry the OpenAI-compat /v1 suffix; it is
// preserved here and rewritten to /api by the vendor.
expect(normalizeSdkBaseUrl("ollama", "http://localhost:11434/v1")).toBe("http://localhost:11434/v1")
})
@@ -429,6 +450,40 @@ describe("buildSessionConfig", () => {
expect(config.providerConfig).not.toHaveProperty("apiKey")
})
it("preserves rich SDK catalog entries without extension-side replacement", async () => {
const expectedModel = structuredClone((await LlmsModels.getModelsForProvider("anthropic"))["claude-sonnet-4-6"])
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "anthropic",
actModeApiModelId: "claude-sonnet-4-6",
apiKey: "anthropic-key",
} as any)
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
const knownModel = (config.providerConfig as any).knownModels["claude-sonnet-4-6"]
expect(knownModel).toEqual(expectedModel)
expect(knownModel.capabilities).toEqual(
expect.arrayContaining(["images", "files", "tools", "reasoning", "structured_output", "temperature", "prompt-cache"]),
)
expect(knownModel.pricing).toEqual(expectedModel.pricing)
expect(knownModel.releaseDate).toBe(expectedModel.releaseDate)
expect(knownModel.family).toBe(expectedModel.family)
})
it("keeps session creation non-fatal when known-model lookup fails", async () => {
const lookupError = new Error("registry unavailable")
const getModelsSpy = vi.spyOn(LlmsModels, "getModelsForProvider").mockRejectedValueOnce(lookupError)
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
expect(config.providerConfig).not.toHaveProperty("knownModels")
expect(Logger.warn).toHaveBeenCalledWith(
"[SessionFactory] Failed to resolve known models for provider=anthropic:",
lookupError,
)
getModelsSpy.mockRestore()
})
it("passes OpenAI Compatible max output tokens as an explicit request limit", async () => {
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "openai",
@@ -451,11 +506,87 @@ describe("buildSessionConfig", () => {
expect(config.providerId).toBe("openai-compatible")
expect(config.modelId).toBe("custom-reasoner")
expect(config.knownModels).toBeUndefined()
expect((config.providerConfig as any).knownModels).toBeUndefined()
expect((config.providerConfig as any).knownModels).toBeDefined()
expect((config.providerConfig as any).maxOutputTokens).toBeUndefined()
expect((config as any).maxTokensPerTurn).toBe(4_096)
})
it("uses OpenAI Compatible overrides from models.json for runtime request settings", async () => {
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "openai",
actModeOpenAiModelId: "custom-reasoner",
openAiApiKey: "openai-compatible-key",
openAiBaseUrl: "https://openai-compatible.example/v1",
actModeOpenAiModelInfo: { supportsPromptCache: false },
} as any)
createProviderConfigStore().commitSelection(parseProviderId("openai"), "act", {
providerId: parseProviderId("openai"),
modelId: "custom-reasoner",
overrides: {
name: "Custom Reasoner",
contextWindow: 16_000,
maxInputTokens: 15_000,
maxTokens: 1_234,
capabilities: ["images", "reasoning", "streaming", "tools"],
supportsVision: false,
supportsAttachments: true,
supportsReasoning: false,
temperature: 0,
inputPrice: 1,
outputPrice: 2,
cacheReadsPrice: 0.1,
cacheWritesPrice: 0.5,
apiFormat: ApiFormat.OPENAI_RESPONSES,
},
})
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
const knownModel = (config.providerConfig as any).knownModels["custom-reasoner"]
expect(config.providerId).toBe("openai-compatible")
expect(config.modelId).toBe("custom-reasoner")
expect((config as any).maxTokensPerTurn).toBe(1_234)
expect((config as any).temperature).toBe(0)
expect(knownModel).toMatchObject({
id: "custom-reasoner",
name: "Custom Reasoner",
contextWindow: 16_000,
maxInputTokens: 15_000,
maxTokens: 1_234,
capabilities: ["streaming", "tools", "files"],
apiFormat: "openai-responses",
temperature: 0,
pricing: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0.5 },
})
})
it("keeps -1 OpenAI Compatible values out of request settings and fallback knownModels", async () => {
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "openai",
actModeOpenAiModelId: "custom-reasoner",
openAiApiKey: "openai-compatible-key",
openAiBaseUrl: "https://openai-compatible.example/v1",
actModeOpenAiModelInfo: { supportsPromptCache: false },
} as any)
createProviderConfigStore().commitSelection(parseProviderId("openai"), "act", {
providerId: parseProviderId("openai"),
modelId: "custom-reasoner",
overrides: {
name: "Custom Reasoner",
maxTokens: -1,
temperature: -1,
},
})
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
expect((config as any).maxTokensPerTurn).toBeUndefined()
expect((config as any).temperature).toBeUndefined()
const knownModel = (config.providerConfig as any).knownModels["custom-reasoner"]
expect(knownModel).not.toHaveProperty("maxTokens")
expect(knownModel).not.toHaveProperty("temperature", -1)
})
it("passes OCA reasoning effort from legacy mode settings to SDK sessions", async () => {
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "oca",
+177 -6
View File
@@ -17,7 +17,13 @@ import {
resolveProviderApiKeyFromSettings,
type StartSessionResult,
} from "@cline/core"
import { getGeneratedModelsForProvider, MODEL_COLLECTIONS_BY_PROVIDER_ID } from "@cline/llms"
import type { ModelInfo as SdkModelInfo } from "@cline/llms"
import {
getGeneratedModelsForProvider,
getModelsForProvider,
MODEL_COLLECTIONS_BY_PROVIDER_ID,
OLLAMA_DEFAULT_CONTEXT_WINDOW,
} from "@cline/llms"
import { buildClineSystemPrompt } from "@cline/shared"
import type { ApiConfiguration } from "@shared/api"
import { ClineClient } from "@shared/cline"
@@ -37,7 +43,11 @@ import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
import { type BedrockProviderConfig, buildBedrockProviderConfig } from "./bedrock-config"
import { buildAgentHooks } from "./hooks-adapter"
import { readTaskHistory, resolveDataDir } from "./legacy-state-reader"
import type { ResolvedModelSelection } from "./model-catalog/contracts"
import { nonNegativeFiniteNumber, positiveFiniteNumber, toSdkApiFormat } from "./model-catalog/model-values"
import { parseProviderId } from "./model-catalog/provider-id"
import { toSdkProviderId } from "./model-catalog/sdk-provider-id"
import { createProviderConfigStore, resolveRuntimeModelSelection } from "./model-catalog/store"
import { getProviderSettingsManager } from "./provider-migration"
import { buildSapProviderConfig, type SapProviderConfig } from "./sap-config"
import type { SdkSessionHost } from "./session-host"
@@ -209,8 +219,73 @@ function resolveOcaReasoningConfig(mode: Mode, apiConfig: ApiConfiguration | und
function resolveOpenAiCompatibleMaxTokens(config: ApiConfiguration | undefined, mode: Mode): number | undefined {
const modelInfo = mode === "plan" ? config?.planModeOpenAiModelInfo : config?.actModeOpenAiModelInfo
const maxTokens = modelInfo?.maxTokens
return typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0 ? maxTokens : undefined
return positiveFiniteNumber(modelInfo?.maxTokens)
}
function toSdkModelInfo(selection: ResolvedModelSelection): SdkModelInfo {
const modelInfo = selection.modelInfo
const capabilities = new Set<NonNullable<SdkModelInfo["capabilities"]>[number]>(
(selection.overrides?.capabilities ?? []) as NonNullable<SdkModelInfo["capabilities"]>,
)
const setCapability = (capability: NonNullable<SdkModelInfo["capabilities"]>[number], enabled: boolean): void => {
if (enabled) capabilities.add(capability)
else capabilities.delete(capability)
}
if (modelInfo.supportsImages !== undefined) setCapability("images", modelInfo.supportsImages)
setCapability("prompt-cache", modelInfo.supportsPromptCache)
if (modelInfo.supportsReasoning !== undefined) setCapability("reasoning", modelInfo.supportsReasoning)
if (selection.overrides?.supportsAttachments !== undefined) setCapability("files", selection.overrides.supportsAttachments)
const maxTokens = positiveFiniteNumber(modelInfo.maxTokens)
const contextWindow = positiveFiniteNumber(modelInfo.contextWindow)
const maxInputTokens = positiveFiniteNumber(selection.overrides?.maxInputTokens)
const temperature = nonNegativeFiniteNumber(modelInfo.temperature)
const inputPrice = nonNegativeFiniteNumber(modelInfo.inputPrice)
const outputPrice = nonNegativeFiniteNumber(modelInfo.outputPrice)
const cacheRead = nonNegativeFiniteNumber(modelInfo.cacheReadsPrice)
const cacheWrite = nonNegativeFiniteNumber(modelInfo.cacheWritesPrice)
const apiFormat = toSdkApiFormat(modelInfo.apiFormat)
const hasPricing =
inputPrice !== undefined || outputPrice !== undefined || cacheRead !== undefined || cacheWrite !== undefined
return {
id: selection.modelId,
name: modelInfo.name ?? selection.modelId,
...(maxTokens !== undefined ? { maxTokens } : {}),
...(contextWindow !== undefined ? { contextWindow } : {}),
...(maxInputTokens !== undefined ? { maxInputTokens } : {}),
...(capabilities.size > 0 ? { capabilities: [...capabilities] } : {}),
...(apiFormat !== undefined ? { apiFormat } : {}),
...(temperature !== undefined ? { temperature } : {}),
...(hasPricing
? {
pricing: {
...(inputPrice !== undefined ? { input: inputPrice } : {}),
...(outputPrice !== undefined ? { output: outputPrice } : {}),
...(cacheRead !== undefined ? { cacheRead } : {}),
...(cacheWrite !== undefined ? { cacheWrite } : {}),
},
}
: {}),
}
}
function resolveCommittedRuntimeModel(
providerId: string,
mode: Mode,
modelId: string | undefined,
): ResolvedModelSelection | undefined {
if (!modelId) {
return undefined
}
try {
const parsedProviderId = parseProviderId(providerId)
const selection = createProviderConfigStore().readSelection(parsedProviderId, mode)
return selection?.modelId === modelId ? selection : resolveRuntimeModelSelection(parsedProviderId, modelId)
} catch (error) {
Logger.warn(`[SessionFactory] Failed to resolve committed model settings for provider=${providerId}:`, error)
return undefined
}
}
// ---------------------------------------------------------------------------
@@ -303,8 +378,21 @@ const PROVIDER_MODEL_ID_MAP: Record<string, { plan: keyof ApiConfiguration; act:
const DEFAULT_PROVIDER_ID = "cline"
/**
* Providers whose model list comes from a live local endpoint (Ollama's
* `/api/tags`, LM Studio's `/v1/models`). Their installed models are the only
* meaningful catalog; a bundled-catalog default would silently select a model
* the user never installed (e.g. an Ollama Cloud nemotron model).
*/
function providerHasLocalModelSource(providerId: string): boolean {
return Boolean(MODEL_COLLECTIONS_BY_PROVIDER_ID[toSdkProviderId(providerId)]?.provider.modelsSourceUrl)
}
export function getDefaultModelIdForProvider(providerId: string): string | undefined {
const sdkProviderId = toSdkProviderId(providerId)
if (providerHasLocalModelSource(providerId)) {
return undefined
}
const collection = MODEL_COLLECTIONS_BY_PROVIDER_ID[sdkProviderId]
if (!collection) {
return undefined
@@ -479,6 +567,42 @@ export function resolveVertexProviderConfig(config: ApiConfiguration): Pick<Prov
}
}
type OllamaProviderConfig = {
modelInfo?: { id: string; name: string; contextWindow: number }
timeoutMs?: number
}
/**
* Resolve the user's "Model Context Window" setting for Ollama and surface it
* as the selected model's `contextWindow`. The gateway carries it on the
* resolved model definition, and the Ollama vendor maps it onto the wire as
* `options.num_ctx` — without it Ollama loads every model with its 4096-token
* server default. Keeping it on the model also means context management
* budgets against the window Ollama actually applies (Ollama truncates the
* prompt to `num_ctx` server-side).
*/
export function resolveOllamaProviderConfig(config: ApiConfiguration, modelId: string | undefined): OllamaProviderConfig {
// providers.json (`contextWindow`) is the source of truth; the legacy
// StateManager string is a migration fallback (the config store mirrors
// writes to both).
let settingsContextWindow: number | undefined
try {
const value = getProviderSettingsManager().getProviderSettings("ollama")?.contextWindow
settingsContextWindow = typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined
} catch {
Logger.warn("[SessionFactory] Failed to read Ollama settings from providers.json")
}
const raw = config.ollamaApiOptionsCtxNum?.trim()
const parsed = raw ? Number(raw) : Number.NaN
const legacyContextWindow = Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : undefined
const contextWindow = settingsContextWindow ?? legacyContextWindow ?? OLLAMA_DEFAULT_CONTEXT_WINDOW
const timeoutMs = config.requestTimeoutMs
return {
...(typeof timeoutMs === "number" && timeoutMs > 0 ? { timeoutMs } : {}),
...(modelId ? { modelInfo: { id: modelId, name: modelId, contextWindow } } : {}),
}
}
export function resolveBaseUrl(providerId: string, config: ApiConfiguration): string | undefined {
const baseUrlMap: Record<string, keyof ApiConfiguration> = {
anthropic: "anthropicBaseUrl",
@@ -536,6 +660,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
let bedrockProviderConfig: BedrockProviderConfig | undefined
let vertexProviderConfig: Pick<ProviderSettings, "gcp" | "region"> | undefined
let sapProviderConfig: SapProviderConfig | undefined
let ollamaProviderConfig: ReturnType<typeof resolveOllamaProviderConfig> | undefined
try {
const stateManager = StateManager.get()
@@ -571,6 +696,10 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
baseUrl = sapProviderConfig.baseUrl
}
if (providerId === "ollama") {
ollamaProviderConfig = resolveOllamaProviderConfig(apiConfig, modelId)
}
Logger.log(
`[SessionFactory] Resolved from StateManager: provider=${providerId}, model=${modelId}, hasApiKey=${!!apiKey}`,
)
@@ -606,12 +735,31 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
// Final defaults. Keep this aligned with the provider catalog so the UI and
// session factory share one source of truth for default models.
providerId = providerId ?? DEFAULT_PROVIDER_ID
modelId = modelId ?? getDefaultModelIdForProvider(providerId) ?? getDefaultModelIdForProvider(DEFAULT_PROVIDER_ID) ?? ""
if (!modelId && providerHasLocalModelSource(providerId)) {
// Local-model-source providers: the committed selection lives in
// providers.json when the legacy state slot is empty (e.g. configs
// created through the SDK settings store). Never fall through to a
// catalog default — an empty model id surfaces an explicit "select a
// model" state instead of silently running a model the user never chose.
try {
modelId = getProviderSettingsManager().getProviderSettings(providerSettingsProviderId(providerId))?.model?.trim()
} catch {
Logger.warn(`[SessionFactory] Failed to read ${providerId} model from providers.json`)
}
modelId = modelId || ""
} else {
modelId = modelId ?? getDefaultModelIdForProvider(providerId) ?? getDefaultModelIdForProvider(DEFAULT_PROVIDER_ID) ?? ""
}
if (!apiKey && apiConfig) {
apiKey = resolveApiKey(providerId, apiConfig)
}
apiKey = apiKey ?? ""
const maxTokensPerTurn = providerId === "openai" ? resolveOpenAiCompatibleMaxTokens(apiConfig, mode) : undefined
const committedRuntimeModel = resolveCommittedRuntimeModel(providerId, mode, modelId)
const overriddenMaxTokens = committedRuntimeModel?.overrides?.maxTokens
const maxTokensPerTurn =
positiveFiniteNumber(overriddenMaxTokens) ??
(providerId === "openai" ? resolveOpenAiCompatibleMaxTokens(apiConfig, mode) : undefined)
const temperature = nonNegativeFiniteNumber(committedRuntimeModel?.overrides?.temperature)
const reasoningConfig =
providerId === "oca"
? (resolveOcaReasoningConfig(mode, apiConfig) ?? resolveProviderReasoningConfig(providerId))
@@ -662,13 +810,34 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
const sdkProviderId = toSdkProviderId(providerId)
const hostIdentity = await resolveHostIdentity()
const isMultiRoot = await resolveIsMultiRootWorkspace()
let knownModels: Awaited<ReturnType<typeof getModelsForProvider>> | undefined
try {
// Constructing the settings manager loads providers.json and models.json into
// the @cline/llms registry. Reading models from that registry ensures custom
// model overrides are included in the inference provider config, not just in
// the webview/display path.
getProviderSettingsManager(resolveDataDir())
knownModels = await getModelsForProvider(sdkProviderId)
// Only inject host-resolved metadata that carries real information
// (catalog/state base or user overrides). Pure fallback fabrications
// must not reach the runtime; the SDK's own resolution handles those.
const isPureFallbackModel = committedRuntimeModel?.modelInfoSource === "fallback" && !committedRuntimeModel.overrides
if (committedRuntimeModel && !isPureFallbackModel && !knownModels?.[modelId]) {
knownModels = {
...(knownModels ?? {}),
[modelId]: toSdkModelInfo(committedRuntimeModel),
}
}
} catch (error) {
Logger.warn(`[SessionFactory] Failed to resolve known models for provider=${sdkProviderId}:`, error)
}
// Always pass a providerConfig so the proxy/CA-aware fetch reaches the SDK
// gateway; without it the agent loop uses bare global fetch and corporate
// proxy/self-signed CA setups fail on JetBrains and CLI. Cloud providers
// additionally need structured options (region/project/auth/SAP OAuth), which core
// reads from providerConfig in createAgentModelFromConfig.
const cloudProviderConfig = bedrockProviderConfig ?? vertexProviderConfig ?? sapProviderConfig
const cloudProviderConfig = bedrockProviderConfig ?? vertexProviderConfig ?? sapProviderConfig ?? ollamaProviderConfig
// Spread the cloud config first so the explicit fields below — notably the
// proxy/CA-aware fetch — can never be clobbered if those types gain matching keys.
const providerConfig = {
@@ -677,6 +846,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
modelId,
...(apiKey ? { apiKey } : {}),
...(baseUrl !== undefined ? { baseUrl } : {}),
...(knownModels && Object.keys(knownModels).length > 0 ? { knownModels } : {}),
fetch,
}
@@ -707,6 +877,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
mode: mode === "plan" ? "plan" : "act",
...reasoningConfig,
...(maxTokensPerTurn !== undefined ? { maxTokensPerTurn } : {}),
...(temperature !== undefined ? { temperature } : {}),
maxIterations: undefined,
logger: sdkLogger,
extensionContext: {
@@ -3,10 +3,10 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"
import type {
EffectiveProviderConfig,
Fingerprint,
ModelSelection,
ProviderConfigChange,
ProviderConfigReader,
ProviderModelsResult,
ResolvedModelSelection,
} from "./contracts"
import { computeConfigFingerprint } from "./fingerprint"
import { parseProviderId } from "./provider-id"
@@ -95,7 +95,7 @@ function record(
}
}
function makeReader(initialConfig: EffectiveProviderConfig, selection?: ModelSelection): TestReader {
function makeReader(initialConfig: EffectiveProviderConfig, selection?: ResolvedModelSelection): TestReader {
let config = initialConfig
const listeners = new Set<(event: ProviderConfigChange) => void>()
return {
@@ -241,7 +241,7 @@ describe("ProviderCatalog Phase 3.2 resolveModels happy path", () => {
})
const providerId = parseProviderId("openrouter")
const config: EffectiveProviderConfig = { providerId, apiKey: "secret", baseUrl: "https://provider.example.com" }
const selection: ModelSelection = { providerId, modelId: "selected", modelInfo }
const selection: ResolvedModelSelection = { providerId, modelId: "selected", modelInfo }
const reader = makeReader(config, selection)
const catalog = createProviderCatalog(reader)
@@ -507,7 +507,7 @@ describe("ProviderCatalog Phase 3.4 store-driven invalidation", () => {
const providerId = parseProviderId("openrouter")
const reader = makeReader({ providerId, apiKey: "same" })
const catalog = createProviderCatalog(reader)
const selection: ModelSelection = { providerId, modelId: "different", modelInfo }
const selection: ResolvedModelSelection = { providerId, modelId: "different", modelInfo }
const first = await catalog.resolveModels(providerId)
reader.emit({ kind: "selection", providerId, mode: "act", selection })
@@ -692,7 +692,7 @@ describe("ProviderCatalog Phase 3.6 subscribe", () => {
const reader = makeReader({ providerId, baseUrl: "http://localhost:11434/v1" })
const catalog = createProviderCatalog(reader)
const listener = vi.fn()
const selection: ModelSelection = { providerId, modelId: "custom:latest", modelInfo }
const selection: ResolvedModelSelection = { providerId, modelId: "custom:latest", modelInfo }
catalog.subscribe(providerId, listener)
reader.emit({ kind: "selection", providerId, mode: "act", selection })
+68 -17
View File
@@ -68,8 +68,8 @@ export type Fingerprint = string & { readonly [FingerprintBrand]: void }
* - Two reads with no intervening write return structurally equal values.
* - Consumers must not mutate. The shape is `Readonly`.
*
* Mode-dependent selection (modelId, modelInfo) is *not* part of this
* type. Use `ProviderConfigStore.readSelection(providerId, mode)`.
* Mode-dependent selection is *not* part of this type. Use
* `ProviderConfigStore.readSelection(providerId, mode)`.
*/
export interface AwsProviderConfig {
readonly accessKey?: string
@@ -98,6 +98,12 @@ export interface EffectiveProviderConfig {
readonly region?: string
readonly aws?: AwsProviderConfig
readonly gcp?: GcpProviderConfig
/**
* Provider-level context window (providers.json `contextWindow`).
* Provider-neutral: for Ollama it maps to `options.num_ctx` at the
* vendor boundary.
*/
readonly contextWindow?: number
/**
* OAuth-style auth bundle (e.g. cline provider's WorkOS token).
* Compatible with `apiKey`; some providers populate both.
@@ -119,9 +125,9 @@ export interface EffectiveProviderConfig {
* A patch describing a field-level write to `ProviderConfigStore`.
*
* Invariant: `ProviderConfigPatch` cannot describe a model selection. The
* type does not contain `modelId` or `modelInfo`. To write a selection,
* use `commitSelection`, which is a structurally distinct method on the
* store.
* type does not contain `modelId` or per-model overrides. To write a
* selection, use `commitSelection`, which is a structurally distinct method
* on the store.
*
* Empty patches are allowed and are no-ops. A field present with value
* `null` means "clear this field"; an absent field means "leave unchanged."
@@ -140,6 +146,7 @@ export interface ProviderConfigPatch {
readonly region?: string | null
readonly aws?: AwsProviderConfig | null
readonly gcp?: GcpProviderConfig | null
readonly contextWindow?: number | null
readonly auth?: {
readonly accessToken?: string
readonly refreshToken?: string
@@ -153,22 +160,65 @@ export interface ProviderConfigPatch {
// Model selection
// ---------------------------------------------------------------------------
/** Per-model metadata overrides authored by the user for custom models. */
export interface ModelSelectionOverrides {
readonly name?: string
readonly maxTokens?: number
readonly contextWindow?: number
readonly maxInputTokens?: number
readonly capabilities?: readonly string[]
readonly supportsVision?: boolean
readonly supportsAttachments?: boolean
readonly supportsReasoning?: boolean
readonly inputPrice?: number
readonly outputPrice?: number
readonly cacheReadsPrice?: number
readonly cacheWritesPrice?: number
readonly temperature?: number
readonly apiFormat?: ModelInfo["apiFormat"]
readonly isR1FormatRequired?: boolean
}
/**
* A user's committed model selection. The triple is atomic by type: every
* write of `modelId` carries its `modelInfo` envelope, and vice versa.
* A user's committed model selection. The committed write stores only the
* selected model id plus optional user-authored overrides. `ModelInfo` is
* derived by the host from the SDK catalog, then layered with `models.json`
* overrides, then per-provider safe fallback metadata.
*
* Invariants:
* - `modelInfo` was either taken from a `ProviderCatalog.resolveModels`
* result, or constructed from per-provider safe defaults when the user
* entered a custom id manually. Either way it represents the picker's
* best knowledge at the moment of commit. The runtime uses it verbatim.
* - The stored selection wins over later SDK catalog changes. Refresh
* does not retroactively change committed selections.
* - The webview does not commit a `ModelInfo` snapshot.
* - Catalog metadata updates may affect the resolved `ModelInfo` for an
* existing selection unless the user has explicitly overridden the field.
*/
export interface ModelSelection {
readonly providerId: ProviderId
readonly modelId: string
readonly overrides?: ModelSelectionOverrides
}
/** A committed selection as read back by consumers that need display/runtime metadata. */
export interface ResolvedModelSelection extends ModelSelection {
readonly modelInfo: ModelInfo
/**
* Where the base `modelInfo` came from, before overrides were applied:
*
* - "catalog" — SDK catalog metadata (generated snapshot or registry).
* - "state" — the mode-specific `*ModeModelInfo` snapshot persisted by
* the picker at selection time. Authoritative for dynamic-list providers
* (openrouter, litellm, requesty, …) whose models are not in the static
* catalog.
* - "fallback" — provider-safe defaults fabricated because nothing better
* was available. Consumers should treat a "fallback" resolution without
* overrides as weak data and prefer live catalog lookups over it.
*/
readonly modelInfoSource?: "catalog" | "state" | "fallback"
/**
* The base metadata `modelInfo` was resolved from, before overrides were
* applied. Persisted (rather than the resolved value) into the legacy
* state snapshot so that deleting an override cannot resurrect it from a
* snapshot it was previously baked into.
*/
readonly baseModelInfo?: ModelInfo
}
// ---------------------------------------------------------------------------
@@ -190,7 +240,7 @@ export type ProviderConfigChange =
readonly kind: "selection"
readonly providerId: ProviderId
readonly mode: Mode
readonly selection: ModelSelection
readonly selection: ResolvedModelSelection
}
export type ProviderConfigChangeListener = (event: ProviderConfigChange) => void
@@ -317,7 +367,7 @@ export interface ProviderModelsEvent {
*/
export interface ProviderConfigReader {
read(providerId: ProviderId): EffectiveProviderConfig
readSelection(providerId: ProviderId, mode: Mode): ModelSelection | undefined
readSelection(providerId: ProviderId, mode: Mode): ResolvedModelSelection | undefined
subscribe(listener: ProviderConfigChangeListener): Disposable
}
@@ -351,8 +401,9 @@ export interface ProviderConfigStore extends ProviderConfigReader {
write(providerId: ProviderId, patch: ProviderConfigPatch): EffectiveProviderConfig
/**
* Commit a model selection atomically with its info envelope. The only
* entry point that writes `{providerId, modelId, modelInfo}` triples.
* Commit a model ID atomically with optional user-authored overrides.
* Supplying overrides replaces that model's stored override entry; omitting
* them leaves the existing entry unchanged.
*
* I2: refresh handlers do not have access to this method by type, since
* `ProviderCatalog` holds only a `ProviderConfigReader`.
@@ -54,7 +54,27 @@ describe("buildEffectiveProviderConfig", () => {
providerId: parseProviderId("ollama"),
apiKey: "provider-ollama-key",
baseUrl: "http://state-ollama:11434",
extras: { ollamaApiOptionsCtxNum: "8192" },
// The legacy state string surfaces as the provider-neutral
// contextWindow when providers.json has none.
contextWindow: 8192,
})
})
it("prefers the providers.json contextWindow over the legacy Ollama state key", async () => {
const { buildEffectiveProviderConfig } = await import("./effective-config")
mocks.setProviderSettings({
ollama: {
provider: "ollama",
contextWindow: 65536,
},
})
mocks.setApiConfiguration({
ollamaApiOptionsCtxNum: "8192",
})
expect(buildEffectiveProviderConfig(parseProviderId("ollama"))).toEqual({
providerId: parseProviderId("ollama"),
contextWindow: 65536,
})
})
@@ -18,6 +18,7 @@ type ProviderSettingsLike = {
readonly region?: string
readonly aws?: AwsProviderConfig
readonly gcp?: GcpProviderConfig
readonly contextWindow?: number
readonly auth?: AuthConfig
readonly extras?: ExtrasConfig
}
@@ -102,7 +103,6 @@ const headerFields: Partial<Record<string, keyof ApiConfiguration>> = {
}
const extrasFields: Partial<Record<string, Partial<Record<string, keyof ApiConfiguration>>>> = {
ollama: { ollamaApiOptionsCtxNum: "ollamaApiOptionsCtxNum" },
lmstudio: { lmStudioMaxTokens: "lmStudioMaxTokens" },
litellm: { liteLlmUsePromptCache: "liteLlmUsePromptCache" },
openrouter: { openRouterProviderSorting: "openRouterProviderSorting" },
@@ -157,6 +157,14 @@ function readBoolean(record: Record<string, unknown>, key: string): boolean | un
return typeof value === "boolean" ? value : undefined
}
function readPositiveInteger(value: unknown): number | undefined {
const parsed = typeof value === "string" ? Number(value) : value
if (typeof parsed === "number" && Number.isFinite(parsed) && parsed > 0) {
return Math.floor(parsed)
}
return undefined
}
function readGcp(record: Record<string, unknown>): GcpProviderConfig | undefined {
const gcp = record.gcp
if (!isPlainRecord(gcp)) {
@@ -206,6 +214,7 @@ function readProviderSettings(providerId: ProviderId): ConfigParts {
region: readString(settings, "region"),
aws: readAws(settings),
gcp: readGcp(settings),
contextWindow: readPositiveInteger(settings.contextWindow),
auth: readAuth(settings),
extras: isPlainRecord(settings.extras) ? settings.extras : undefined,
} satisfies ProviderSettingsLike
@@ -298,6 +307,16 @@ function readStateAws(provider: string, config: ApiConfiguration): AwsProviderCo
return Object.values(aws).some((value) => value !== undefined) ? aws : undefined
}
function readStateContextWindow(provider: string, config: ApiConfiguration): number | undefined {
// Only Ollama has a legacy context-window state key; other providers keep
// theirs in providers.json exclusively.
if (provider !== "ollama") {
return undefined
}
return readPositiveInteger(config.ollamaApiOptionsCtxNum)
}
function readStateConfig(providerId: ProviderId, config: ApiConfiguration): ConfigParts {
const provider = providerId.toString()
return {
@@ -308,6 +327,7 @@ function readStateConfig(providerId: ProviderId, config: ApiConfiguration): Conf
region: readStringFromConfig(config, regionFields[provider]),
aws: readStateAws(provider, config),
gcp: readStateGcp(provider, config),
contextWindow: readStateContextWindow(provider, config),
auth: readStateAuth(provider, config),
extras: readStateExtras(provider, config),
}
@@ -372,6 +392,10 @@ export function buildEffectiveProviderConfig(providerId: ProviderId): EffectiveP
// fields as a fallback for old installs, but let providers.json win when both exist.
assignIfDefined(merged, "aws", mergeAws(stateConfig.aws, providerSettings.aws))
assignIfDefined(merged, "gcp", mergeGcp(stateConfig.gcp, providerSettings.gcp))
// providers.json is the source of truth for the context window; the legacy
// Ollama StateManager key is a migration fallback (the store mirrors writes
// to both).
assignIfDefined(merged, "contextWindow", providerSettings.contextWindow ?? stateConfig.contextWindow)
assignIfDefined(merged, "auth", stateConfig.auth ?? providerSettings.auth)
assignIfDefined(merged, "extras", mergeExtras(providerSettings.extras, stateConfig.extras))
@@ -170,6 +170,9 @@ export function computeConfigFingerprint(providerId: ProviderId, config: Effecti
region: config.region ?? null,
aws: sanitizeAws(config.aws),
gcp: sanitizeGcp(config.gcp),
// The context window is not a secret; include it raw so changes
// invalidate cached model lists.
contextWindow: config.contextWindow ?? null,
extras: sanitizeExtras(config.extras),
auth: {
accountId: config.auth?.accountId ?? null,
@@ -1,7 +1,8 @@
/**
* Applies the `ModelInfo` fields the extension owns locally, on top of
* an adapted SDK `ModelInfo`. Today this is just Vertex's
* `supportsGlobalEndpoint` allowlist (see `./vertex-global-endpoint.ts`).
* an adapted SDK `ModelInfo`. Today this is Vertex's
* `supportsGlobalEndpoint` allowlist (see `./vertex-global-endpoint.ts`)
* and Ollama's effective context window.
*
* Both the model-list resolution path (`resolveSdkModels`) and the
* single-model lookup path (`resolveModelInfo`) pass adapted
@@ -10,13 +11,52 @@
* flags upstream, the override and this file can be removed together.
*/
import { OLLAMA_DEFAULT_CONTEXT_WINDOW } from "@cline/llms"
import type { ModelInfo } from "@shared/api"
import { StateManager } from "@/core/storage/StateManager"
import { getProviderSettingsManager } from "../provider-migration"
import type { ProviderId } from "./contracts"
import { vertexModelSupportsGlobalEndpoint } from "./vertex-global-endpoint"
/**
* The context window Ollama actually applies is the requested `num_ctx`,
* not the model's native maximum — Ollama truncates the prompt to it
* server-side. Surface the user's "Model Context Window" setting (or the
* request default) instead of catalog/safe-default values so the chat
* indicator and context management match reality.
*/
function resolveOllamaContextWindow(): number {
// providers.json (`contextWindow`) is the source of truth; the legacy
// StateManager string is a migration fallback (the config store mirrors
// writes to both).
try {
const value = getProviderSettingsManager().getProviderSettings("ollama")?.contextWindow
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return Math.floor(value)
}
} catch {
// providers.json unavailable — fall through to the legacy state key.
}
try {
const raw = StateManager.get().getApiConfiguration().ollamaApiOptionsCtxNum?.trim()
if (raw) {
const value = Number(raw)
if (Number.isFinite(value) && value > 0) {
return Math.floor(value)
}
}
} catch {
// StateManager unavailable (e.g. tests) — fall through to the default.
}
return OLLAMA_DEFAULT_CONTEXT_WINDOW
}
export function applyHostModelInfoOverrides(providerId: ProviderId, modelId: string, modelInfo: ModelInfo): ModelInfo {
if (providerId === "vertex" && vertexModelSupportsGlobalEndpoint(providerId, modelId)) {
return { ...modelInfo, supportsGlobalEndpoint: true }
}
if (providerId === "ollama") {
return { ...modelInfo, contextWindow: resolveOllamaContextWindow() }
}
return modelInfo
}
@@ -0,0 +1,45 @@
import type { ModelInfo } from "@shared/api"
import { ApiFormat } from "@shared/proto/cline/models"
/** SDK string spelling of an API format (matches @cline/shared ApiFormatSchema). */
export type SdkApiFormatString = "r1" | "openai-responses" | "default"
export function finiteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined
}
export function positiveFiniteNumber(value: unknown): number | undefined {
const number = finiteNumber(value)
return number !== undefined && number > 0 ? number : undefined
}
export function nonNegativeFiniteNumber(value: unknown): number | undefined {
const number = finiteNumber(value)
return number !== undefined && number >= 0 ? number : undefined
}
export function toSdkApiFormat(apiFormat: ModelInfo["apiFormat"]): SdkApiFormatString | undefined {
switch (apiFormat) {
case ApiFormat.R1_CHAT:
return "r1"
case ApiFormat.OPENAI_RESPONSES:
return "openai-responses"
case ApiFormat.OPENAI_CHAT:
return "default"
default:
return undefined
}
}
export function fromSdkApiFormat(apiFormat: string | undefined): ModelInfo["apiFormat"] | undefined {
switch (apiFormat) {
case "r1":
return ApiFormat.R1_CHAT
case "openai-responses":
return ApiFormat.OPENAI_RESPONSES
case "default":
return ApiFormat.OPENAI_CHAT
default:
return undefined
}
}
+572 -29
View File
@@ -1,4 +1,6 @@
import type { ApiConfiguration, ModelInfo } from "@shared/api"
import { syncStoredProviderRegistration } from "@cline/core"
import { type ApiConfiguration, type ModelInfo, openAiModelInfoSafeDefaults } from "@shared/api"
import { ApiFormat } from "@shared/proto/cline/models"
import { beforeEach, describe, expect, it, vi } from "vitest"
import type { ProviderConfigChange } from "./contracts"
import { parseProviderId } from "./provider-id"
@@ -7,6 +9,11 @@ const mocks = vi.hoisted(() => {
type MockApiConfiguration = ApiConfiguration & { planActSeparateModelsSetting?: boolean }
let apiConfiguration: MockApiConfiguration = {}
let providerSettingsById: Record<string, Record<string, unknown>> = {}
let generatedModelsByProvider: Record<string, Record<string, ModelInfo>> = {}
let modelsFile: { version: 1; providers: Record<string, { models?: Record<string, Record<string, unknown>> }> } = {
version: 1,
providers: {},
}
const saveProviderSettings = vi.fn((settings: Record<string, unknown>, _options?: { setLastUsed?: boolean }) => {
const provider = settings.provider
if (typeof provider !== "string") {
@@ -20,6 +27,8 @@ const mocks = vi.hoisted(() => {
reset(): void {
apiConfiguration = {}
providerSettingsById = {}
generatedModelsByProvider = {}
modelsFile = { version: 1, providers: {} }
saveProviderSettings.mockClear()
},
setApiConfiguration(value: MockApiConfiguration): void {
@@ -28,6 +37,12 @@ const mocks = vi.hoisted(() => {
setProviderSettings(value: Record<string, Record<string, unknown>>): void {
providerSettingsById = { ...value }
},
setGeneratedModels(providerId: string, models: Record<string, ModelInfo>): void {
generatedModelsByProvider = { ...generatedModelsByProvider, [providerId]: models }
},
getGeneratedModels(providerId: string): Record<string, ModelInfo> {
return generatedModelsByProvider[providerId] ?? {}
},
getSavedProviderSettings(providerId: string): Record<string, unknown> | undefined {
return providerSettingsById[providerId]
},
@@ -37,6 +52,12 @@ const mocks = vi.hoisted(() => {
getSaveProviderSettingsMock(): typeof saveProviderSettings {
return saveProviderSettings
},
getModelsFile() {
return modelsFile
},
setModelsFile(value: typeof modelsFile): void {
modelsFile = value
},
getStateManager() {
return {
getApiConfiguration: () => ({ ...apiConfiguration }),
@@ -69,11 +90,24 @@ vi.mock("../provider-migration", () => ({
getProviderSettingsManager: mocks.getProviderSettingsManager,
}))
vi.mock("@cline/core", () => ({
syncStoredProviderRegistration: vi.fn(),
readModelsFileSync: vi.fn(() => mocks.getModelsFile()),
resolveModelsRegistryPath: vi.fn(() => "/tmp/models.json"),
writeModelsFileSync: vi.fn((_filePath: string, state: ReturnType<typeof mocks.getModelsFile>) => mocks.setModelsFile(state)),
}))
vi.mock("@cline/llms", () => ({
getGeneratedModelsForProvider: vi.fn((providerId: string) => mocks.getGeneratedModels(providerId)),
MODEL_COLLECTIONS_BY_PROVIDER_ID: {},
}))
const modelInfoA: ModelInfo = {
name: "Model A",
contextWindow: 128_000,
maxTokens: 8_192,
supportsPromptCache: true,
apiFormat: ApiFormat.OPENAI_RESPONSES,
}
const modelInfoB: ModelInfo = {
@@ -83,9 +117,41 @@ const modelInfoB: ModelInfo = {
supportsPromptCache: false,
}
function selectionFromModelInfo(providerId: ReturnType<typeof parseProviderId>, modelId: string, modelInfo: ModelInfo) {
const capabilities: string[] = []
if (modelInfo.supportsPromptCache) capabilities.push("prompt-cache")
if (modelInfo.supportsImages) capabilities.push("images")
if (modelInfo.supportsReasoning) capabilities.push("reasoning")
return {
providerId,
modelId,
overrides: {
name: modelInfo.name,
contextWindow: modelInfo.contextWindow,
maxTokens: modelInfo.maxTokens,
...(modelInfo.apiFormat !== undefined ? { apiFormat: modelInfo.apiFormat } : {}),
...(capabilities.length > 0 ? { capabilities } : {}),
},
}
}
function expectResolvedSelection(
actual: unknown,
selection: ReturnType<typeof selectionFromModelInfo>,
modelInfo: ModelInfo,
): void {
expect(actual).toMatchObject({
providerId: selection.providerId,
modelId: selection.modelId,
overrides: selection.overrides,
modelInfo,
})
}
describe("createProviderConfigStore", () => {
beforeEach(() => {
mocks.reset()
vi.clearAllMocks()
vi.resetModules()
})
@@ -121,22 +187,29 @@ describe("createProviderConfigStore", () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openrouter")
const selection = { providerId, modelId: "anthropic/claude-sonnet-4", modelInfo: modelInfoA }
const selection = selectionFromModelInfo(providerId, "anthropic/claude-sonnet-4", modelInfoA)
store.commitSelection(providerId, "act", selection)
expect(store.readSelection(providerId, "act")).toEqual(selection)
expectResolvedSelection(store.readSelection(providerId, "act"), selection, modelInfoA)
expect(mocks.getModelsFile().providers.openrouter?.models?.["anthropic/claude-sonnet-4"]).toMatchObject({
name: "Model A",
contextWindow: 128_000,
maxTokens: 8_192,
apiFormat: "openai-responses",
capabilities: ["prompt-cache"],
})
})
it("round-trips generic provider selections using the in-process modelInfo envelope", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("deepseek")
const selection = { providerId, modelId: "deepseek-v4-pro", modelInfo: modelInfoA }
const selection = selectionFromModelInfo(providerId, "deepseek-v4-pro", modelInfoA)
store.commitSelection(providerId, "act", selection)
expect(store.readSelection(providerId, "act")).toEqual(selection)
expectResolvedSelection(store.readSelection(providerId, "act"), selection, modelInfoA)
})
it("hydrates a generic provider selection from providers.json after reload", async () => {
@@ -148,6 +221,8 @@ describe("createProviderConfigStore", () => {
expect(store.readSelection(providerId, "act")).toEqual({
providerId,
modelId: "manual-zai-model",
modelInfoSource: "fallback",
baseModelInfo: expect.objectContaining({ name: "manual-zai-model" }),
modelInfo: expect.objectContaining({
name: "manual-zai-model",
supportsPromptCache: false,
@@ -160,27 +235,27 @@ describe("createProviderConfigStore", () => {
const store = createProviderConfigStore()
const geminiProviderId = parseProviderId("gemini")
const deepSeekProviderId = parseProviderId("deepseek")
const geminiSelection = { providerId: geminiProviderId, modelId: "gemini-3.1-pro-preview", modelInfo: modelInfoA }
const deepSeekSelection = { providerId: deepSeekProviderId, modelId: "deepseek-v4-pro", modelInfo: modelInfoB }
const geminiSelection = selectionFromModelInfo(geminiProviderId, "gemini-3.1-pro-preview", modelInfoA)
const deepSeekSelection = selectionFromModelInfo(deepSeekProviderId, "deepseek-v4-pro", modelInfoB)
store.commitSelection(geminiProviderId, "act", geminiSelection)
store.commitSelection(deepSeekProviderId, "act", deepSeekSelection)
expect(store.readSelection(geminiProviderId, "act")).toEqual(geminiSelection)
expect(store.readSelection(deepSeekProviderId, "act")).toEqual(deepSeekSelection)
expectResolvedSelection(store.readSelection(geminiProviderId, "act"), geminiSelection, modelInfoA)
expectResolvedSelection(store.readSelection(deepSeekProviderId, "act"), deepSeekSelection, modelInfoB)
})
it("handles normalized nousResearch provider casing for writes and selections", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("nousResearch")
const selection = { providerId, modelId: "nousresearch/hermes-4-70b", modelInfo: modelInfoA }
const selection = selectionFromModelInfo(providerId, "nousresearch/hermes-4-70b", modelInfoA)
const written = store.write(providerId, { apiKey: "nous-key" })
store.commitSelection(providerId, "act", selection)
expect(written).toEqual({ providerId, apiKey: "nous-key" })
expect(store.readSelection(providerId, "act")).toEqual(selection)
expectResolvedSelection(store.readSelection(providerId, "act"), selection, modelInfoA)
expect(mocks.getSavedProviderSettings("nousResearch")).toMatchObject({
provider: "nousResearch",
apiKey: "nous-key",
@@ -227,6 +302,179 @@ describe("createProviderConfigStore", () => {
})
})
it("lazily migrates meaningful legacy custom-model metadata once", async () => {
const legacyModelInfo = {
name: "Legacy Custom",
maxTokens: 4_096,
contextWindow: 64_000,
supportsImages: false,
supportsPromptCache: true,
supportsReasoning: true,
inputPrice: 1,
outputPrice: 2,
cacheReadsPrice: 0.25,
cacheWritesPrice: 0.5,
temperature: 0.3,
apiFormat: ApiFormat.OPENAI_RESPONSES,
isR1FormatRequired: true,
}
mocks.setApiConfiguration({
actModeOpenAiModelId: "legacy-custom",
actModeOpenAiModelInfo: legacyModelInfo,
})
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const first = store.readSelection(providerId, "act")
const second = store.readSelection(providerId, "act")
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["legacy-custom"]).toEqual({
name: "Legacy Custom",
maxTokens: 4_096,
contextWindow: 64_000,
capabilities: ["prompt-cache"],
supportsVision: false,
supportsReasoning: true,
inputPrice: 1,
outputPrice: 2,
cacheReadsPrice: 0.25,
cacheWritesPrice: 0.5,
temperature: 0.3,
apiFormat: "openai-responses",
isR1FormatRequired: true,
})
expect(first?.overrides).toEqual(second?.overrides)
expect(first?.modelInfo).toMatchObject({
name: "Legacy Custom",
maxTokens: 4_096,
contextWindow: 64_000,
supportsImages: false,
supportsPromptCache: true,
supportsReasoning: true,
inputPrice: 1,
outputPrice: 2,
cacheReadsPrice: 0.25,
cacheWritesPrice: 0.5,
temperature: 0.3,
apiFormat: ApiFormat.R1_CHAT,
})
expect(syncStoredProviderRegistration).toHaveBeenCalledTimes(1)
})
it("does not create migration noise for legacy safe defaults", async () => {
mocks.setApiConfiguration({
actModeOpenAiModelId: "default-custom",
actModeOpenAiModelInfo: { ...openAiModelInfoSafeDefaults, name: "default-custom" },
})
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const first = store.readSelection(providerId, "act")
const second = store.readSelection(providerId, "act")
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["default-custom"]).toBeUndefined()
expect(first?.overrides).toBeUndefined()
expect(second?.overrides).toBeUndefined()
expect(first?.modelInfo).toMatchObject({ contextWindow: 128_000, supportsImages: true, temperature: 0 })
expect(first?.modelInfo.maxTokens).toBeUndefined()
expect(syncStoredProviderRegistration).not.toHaveBeenCalled()
})
it("never overwrites an existing models.json entry during migration", async () => {
mocks.setModelsFile({
version: 1,
providers: {
"openai-compatible": { models: { "existing-custom": { temperature: 0.7 } } },
},
})
mocks.setApiConfiguration({
actModeOpenAiModelId: "existing-custom",
actModeOpenAiModelInfo: { ...openAiModelInfoSafeDefaults, temperature: 0.2 },
})
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const selection = store.readSelection(providerId, "act")
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["existing-custom"]).toEqual({
temperature: 0.7,
})
expect(selection?.overrides).toEqual({ temperature: 0.7 })
expect(selection?.modelInfo.temperature).toBe(0.7)
expect(syncStoredProviderRegistration).not.toHaveBeenCalled()
})
it("does not migrate stale legacy snapshots for catalog-known models", async () => {
mocks.setGeneratedModels("openai-compatible", {
"known-model": {
name: "Current Catalog Model",
contextWindow: 256_000,
supportsPromptCache: false,
temperature: 0.1,
},
})
mocks.setApiConfiguration({
actModeOpenAiModelId: "known-model",
actModeOpenAiModelInfo: {
name: "Stale Catalog Model",
contextWindow: 32_000,
supportsPromptCache: false,
temperature: 0.9,
},
})
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const selection = store.readSelection(providerId, "act")
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["known-model"]).toBeUndefined()
expect(selection?.overrides).toBeUndefined()
expect(selection?.modelInfo).toMatchObject({
name: "Current Catalog Model",
contextWindow: 256_000,
temperature: 0.1,
})
expect(syncStoredProviderRegistration).not.toHaveBeenCalled()
})
it("migrates separate Plan and Act legacy custom models independently", async () => {
mocks.setApiConfiguration({
planActSeparateModelsSetting: true,
planModeOpenAiModelId: "legacy-plan",
planModeOpenAiModelInfo: {
...openAiModelInfoSafeDefaults,
contextWindow: 64_000,
apiFormat: ApiFormat.OPENAI_RESPONSES,
},
actModeOpenAiModelId: "legacy-act",
actModeOpenAiModelInfo: {
...openAiModelInfoSafeDefaults,
maxTokens: 2_048,
isR1FormatRequired: true,
},
})
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const plan = store.readSelection(providerId, "plan")
const act = store.readSelection(providerId, "act")
store.readSelection(providerId, "plan")
store.readSelection(providerId, "act")
expect(mocks.getModelsFile().providers["openai-compatible"]?.models).toMatchObject({
"legacy-plan": { contextWindow: 64_000, apiFormat: "openai-responses" },
"legacy-act": { maxTokens: 2_048, isR1FormatRequired: true },
})
expect(plan?.modelInfo).toMatchObject({ contextWindow: 64_000, apiFormat: ApiFormat.OPENAI_RESPONSES })
expect(act?.modelInfo).toMatchObject({ maxTokens: 2_048, apiFormat: ApiFormat.R1_CHAT })
expect(syncStoredProviderRegistration).toHaveBeenCalledTimes(2)
})
it("preserves migrated OpenAI Compatible settings when committing model selections", async () => {
const { createProviderConfigStore } = await import("./store")
mocks.setProviderSettings({
@@ -237,7 +485,7 @@ describe("createProviderConfigStore", () => {
})
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const selection = { providerId, modelId: "gpt-oss-120b", modelInfo: modelInfoA }
const selection = selectionFromModelInfo(providerId, "gpt-oss-120b", modelInfoA)
store.commitSelection(providerId, "act", selection)
@@ -248,6 +496,242 @@ describe("createProviderConfigStore", () => {
})
})
it("preserves per-model OpenAI Compatible overrides when switching models without new overrides", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const modelASelection = selectionFromModelInfo(providerId, "model-a", modelInfoA)
store.commitSelection(providerId, "act", modelASelection)
store.commitSelection(providerId, "act", { providerId, modelId: "model-b" })
store.commitSelection(providerId, "act", { providerId, modelId: "model-a" })
expectResolvedSelection(store.readSelection(providerId, "act"), modelASelection, modelInfoA)
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["model-a"]).toMatchObject({
name: "Model A",
maxTokens: 8_192,
contextWindow: 128_000,
})
})
it("deletes a model entry when an explicit replacement override set is empty", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
store.commitSelection(providerId, "act", {
providerId,
modelId: "custom-model",
overrides: {
apiFormat: ApiFormat.OPENAI_RESPONSES,
capabilities: ["tools", "streaming"],
temperature: 0.2,
},
})
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["custom-model"]).toMatchObject({
apiFormat: "openai-responses",
capabilities: ["tools", "streaming"],
temperature: 0.2,
})
store.commitSelection(providerId, "act", { providerId, modelId: "custom-model", overrides: {} })
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["custom-model"]).toBeUndefined()
expect(store.readSelection(providerId, "act")?.overrides).toBeUndefined()
})
it("replaces an existing model override set instead of merging stale fields", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
store.commitSelection(providerId, "act", {
providerId,
modelId: "custom-model",
overrides: { apiFormat: ApiFormat.OPENAI_RESPONSES, inputPrice: 1, temperature: 0.2 },
})
store.commitSelection(providerId, "act", {
providerId,
modelId: "custom-model",
overrides: { temperature: 0.4 },
})
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["custom-model"]).toEqual({ temperature: 0.4 })
expect(store.readSelection(providerId, "act")?.overrides).toEqual({ temperature: 0.4 })
})
it.each([
[ApiFormat.OPENAI_CHAT, "default"],
[ApiFormat.R1_CHAT, "r1"],
[ApiFormat.OPENAI_RESPONSES, "openai-responses"],
] as const)("round-trips supported apiFormat %s through models.json", async (apiFormat, storedApiFormat) => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
store.commitSelection(providerId, "act", {
providerId,
modelId: "custom-model",
overrides: { apiFormat },
})
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["custom-model"]).toEqual({
apiFormat: storedApiFormat,
})
expect(store.readSelection(providerId, "act")?.overrides).toEqual({ apiFormat })
})
it("normalizes invalid override values before storage and resolved legacy state", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
store.commitSelection(providerId, "act", {
providerId,
modelId: "custom-model",
overrides: {
name: "Custom model",
maxTokens: -1,
contextWindow: Number.POSITIVE_INFINITY,
maxInputTokens: 0,
capabilities: ["tools", "tools", "vision", "unknown"],
supportsVision: false,
supportsReasoning: false,
inputPrice: Number.NaN,
outputPrice: 2,
cacheReadsPrice: 0,
cacheWritesPrice: -1,
temperature: -1,
apiFormat: 999 as ApiFormat,
},
})
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["custom-model"]).toEqual({
name: "Custom model",
capabilities: ["tools"],
supportsVision: false,
supportsReasoning: false,
outputPrice: 2,
cacheReadsPrice: 0,
})
const selection = store.readSelection(providerId, "act")
expect(selection?.overrides).toEqual({
name: "Custom model",
capabilities: ["tools"],
supportsVision: false,
supportsReasoning: false,
outputPrice: 2,
cacheReadsPrice: 0,
})
expect(selection?.modelInfo.maxTokens).toBeUndefined()
expect(selection?.modelInfo.temperature).toBe(0)
expect(mocks.getApiConfiguration().actModeOpenAiModelInfo).not.toHaveProperty("maxTokens")
expect(mocks.getApiConfiguration().actModeOpenAiModelInfo).not.toHaveProperty("temperature", -1)
expect(syncStoredProviderRegistration).toHaveBeenCalledTimes(1)
})
it("deletes a stored entry when normalization removes every replacement field", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
store.commitSelection(providerId, "act", {
providerId,
modelId: "custom-model",
overrides: { temperature: 0.2 },
})
store.commitSelection(providerId, "act", {
providerId,
modelId: "custom-model",
overrides: {
maxTokens: -1,
contextWindow: 0,
capabilities: ["vision", "unknown"],
inputPrice: Number.NaN,
temperature: -1,
apiFormat: 999 as ApiFormat,
},
})
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["custom-model"]).toBeUndefined()
expect(store.readSelection(providerId, "act")?.overrides).toBeUndefined()
expect(syncStoredProviderRegistration).toHaveBeenCalledTimes(2)
})
it("normalizes invalid values already present in models.json on read", async () => {
const { createProviderConfigStore } = await import("./store")
mocks.setProviderSettings({
"openai-compatible": { provider: "openai-compatible", model: "custom-model" },
})
mocks.setModelsFile({
version: 1,
providers: {
"openai-compatible": {
models: {
"custom-model": {
maxTokens: -1,
contextWindow: 64_000,
inputPrice: -2,
temperature: -1,
capabilities: ["tools"],
},
},
},
},
})
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const selection = store.readSelection(providerId, "act")
expect(selection?.overrides).toEqual({ contextWindow: 64_000, capabilities: ["tools"] })
expect(selection?.modelInfo.maxTokens).toBeUndefined()
expect(selection?.modelInfo.temperature).toBe(0)
expect(syncStoredProviderRegistration).not.toHaveBeenCalled()
})
it("lets explicit capability booleans win and applies the R1 alias deterministically", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
store.commitSelection(providerId, "act", {
providerId,
modelId: "custom-model",
overrides: {
apiFormat: ApiFormat.OPENAI_RESPONSES,
capabilities: ["images", "prompt-cache", "reasoning"],
supportsVision: false,
supportsReasoning: false,
isR1FormatRequired: false,
},
})
let selection = store.readSelection(providerId, "act")
expect(selection?.modelInfo).toMatchObject({
supportsImages: false,
supportsPromptCache: true,
supportsReasoning: false,
apiFormat: ApiFormat.OPENAI_RESPONSES,
})
store.commitSelection(providerId, "act", {
providerId,
modelId: "custom-model",
overrides: {
apiFormat: ApiFormat.OPENAI_RESPONSES,
capabilities: ["prompt-cache"],
supportsVision: true,
isR1FormatRequired: true,
},
})
selection = store.readSelection(providerId, "act")
expect(selection?.modelInfo).toMatchObject({
supportsImages: true,
supportsPromptCache: true,
apiFormat: ApiFormat.R1_CHAT,
})
})
it("keeps OpenAI Compatible Plan and Act selections independent when separate models are enabled", async () => {
const { createProviderConfigStore } = await import("./store")
mocks.setApiConfiguration({ planActSeparateModelsSetting: true })
@@ -259,14 +743,14 @@ describe("createProviderConfigStore", () => {
})
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const planSelection = { providerId, modelId: "plan-openai-model", modelInfo: modelInfoA }
const actSelection = { providerId, modelId: "act-openai-model", modelInfo: modelInfoB }
const planSelection = selectionFromModelInfo(providerId, "plan-openai-model", modelInfoA)
const actSelection = selectionFromModelInfo(providerId, "act-openai-model", modelInfoB)
store.commitSelection(providerId, "plan", planSelection)
store.commitSelection(providerId, "act", actSelection)
expect(store.readSelection(providerId, "plan")).toEqual(planSelection)
expect(store.readSelection(providerId, "act")).toEqual(actSelection)
expectResolvedSelection(store.readSelection(providerId, "plan"), planSelection, modelInfoA)
expectResolvedSelection(store.readSelection(providerId, "act"), actSelection, modelInfoB)
expect(mocks.getApiConfiguration()).toMatchObject({
planModeOpenAiModelId: "plan-openai-model",
planModeOpenAiModelInfo: modelInfoA,
@@ -286,12 +770,12 @@ describe("createProviderConfigStore", () => {
mocks.setApiConfiguration({ planActSeparateModelsSetting: false })
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const selection = { providerId, modelId: "shared-openai-model", modelInfo: modelInfoA }
const selection = selectionFromModelInfo(providerId, "shared-openai-model", modelInfoA)
store.commitSelection(providerId, "act", selection)
expect(store.readSelection(providerId, "plan")).toEqual(selection)
expect(store.readSelection(providerId, "act")).toEqual(selection)
expectResolvedSelection(store.readSelection(providerId, "plan"), selection, modelInfoA)
expectResolvedSelection(store.readSelection(providerId, "act"), selection, modelInfoA)
expect(mocks.getApiConfiguration()).toMatchObject({
planModeOpenAiModelId: "shared-openai-model",
planModeOpenAiModelInfo: modelInfoA,
@@ -320,14 +804,22 @@ describe("createProviderConfigStore", () => {
expect(mocks.getApiConfiguration().zaiApiKey).toBe("shared-zai-key")
})
it("returns undefined from readSelection when modelId or modelInfo is missing", async () => {
it("resolves a bare state modelId with fallback metadata and ignores a bare modelInfo", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openrouter")
// The mode-specific model id alone identifies the selection; commits
// whose resolution was pure fallback intentionally leave the state
// modelInfo snapshot unset.
mocks.setApiConfiguration({ actModeOpenRouterModelId: "anthropic/claude-sonnet-4" })
expect(store.readSelection(providerId, "act")).toBeUndefined()
expect(store.readSelection(providerId, "act")).toMatchObject({
providerId,
modelId: "anthropic/claude-sonnet-4",
modelInfoSource: "fallback",
})
// A modelInfo snapshot without a model id is not a selection.
mocks.setApiConfiguration({ actModeOpenRouterModelInfo: modelInfoA })
expect(store.readSelection(providerId, "act")).toBeUndefined()
})
@@ -337,14 +829,14 @@ describe("createProviderConfigStore", () => {
mocks.setApiConfiguration({ planActSeparateModelsSetting: true })
const store = createProviderConfigStore()
const providerId = parseProviderId("openrouter")
const planSelection = { providerId, modelId: "provider/model-a", modelInfo: modelInfoA }
const actSelection = { providerId, modelId: "provider/model-b", modelInfo: modelInfoB }
const planSelection = selectionFromModelInfo(providerId, "provider/model-a", modelInfoA)
const actSelection = selectionFromModelInfo(providerId, "provider/model-b", modelInfoB)
store.commitSelection(providerId, "plan", planSelection)
store.commitSelection(providerId, "act", actSelection)
expect(store.readSelection(providerId, "plan")).toEqual(planSelection)
expect(store.readSelection(providerId, "act")).toEqual(actSelection)
expectResolvedSelection(store.readSelection(providerId, "plan"), planSelection, modelInfoA)
expectResolvedSelection(store.readSelection(providerId, "act"), actSelection, modelInfoB)
expect(mocks.getSavedProviderSettings("openrouter")).toMatchObject({
provider: "openrouter",
model: "provider/model-b",
@@ -361,7 +853,7 @@ describe("createProviderConfigStore", () => {
})
const store = createProviderConfigStore()
const providerId = parseProviderId("openrouter")
const selection = { providerId, modelId: "provider/model-a", modelInfo: modelInfoA }
const selection = selectionFromModelInfo(providerId, "provider/model-a", modelInfoA)
store.commitSelection(providerId, "act", selection)
@@ -381,7 +873,7 @@ describe("createProviderConfigStore", () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("claude-code")
const selection = { providerId, modelId: "haiku", modelInfo: modelInfoA }
const selection = selectionFromModelInfo(providerId, "haiku", modelInfoA)
store.commitSelection(providerId, "act", selection)
@@ -423,7 +915,7 @@ describe("createProviderConfigStore", () => {
const store = createProviderConfigStore()
const providerId = parseProviderId("openrouter")
const events: ProviderConfigChange[] = []
const selection = { providerId, modelId: "provider/model-a", modelInfo: modelInfoA }
const selection = selectionFromModelInfo(providerId, "provider/model-a", modelInfoA)
store.subscribe((event) => events.push(event))
store.write(providerId, { apiKey: "openrouter-key" })
@@ -431,7 +923,12 @@ describe("createProviderConfigStore", () => {
expect(events.map((event) => event.kind)).toEqual(["fields", "selection"])
expect(events[0]).toMatchObject({ kind: "fields", providerId })
expect(events[1]).toEqual({ kind: "selection", providerId, mode: "act", selection })
expect(events[1]).toEqual({
kind: "selection",
providerId,
mode: "act",
selection: store.readSelection(providerId, "act"),
})
})
it("dispose unregisters listeners", async () => {
@@ -446,4 +943,50 @@ describe("createProviderConfigStore", () => {
expect(listener).not.toHaveBeenCalled()
})
// Contract test against the REAL SDK schemas (imported by relative path,
// bypassing the @cline/core mock above): the store's converters must pass
// every SDK capability through, and a fully-populated stored entry must
// parse under the schema `writeModelsFileSync` enforces in production.
it("round-trips every SDK model capability and a full override set under the real stored-entry schema", async () => {
const { ModelCapabilitySchema } = await import("@cline/shared")
// vi.importActual bypasses the @cline/core mock above and resolves via
// the vitest alias to the stub, which re-exports the real schema.
const { StoredModelEntrySchema } = (await vi.importActual("@cline/core")) as {
StoredModelEntrySchema: { parse(input: unknown): unknown }
}
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
store.commitSelection(providerId, "act", {
providerId,
modelId: "contract-model",
overrides: {
name: "Contract Model",
maxTokens: 1024,
contextWindow: 200_000,
maxInputTokens: 100_000,
capabilities: [...ModelCapabilitySchema.options],
supportsVision: true,
supportsAttachments: true,
supportsReasoning: true,
inputPrice: 0.5,
outputPrice: 1.5,
cacheReadsPrice: 0.1,
cacheWritesPrice: 0.2,
temperature: 0.7,
apiFormat: ApiFormat.OPENAI_RESPONSES,
isR1FormatRequired: true,
},
})
const entry = mocks.getModelsFile().providers["openai-compatible"]?.models?.["contract-model"]
expect(entry).toBeDefined()
// No SDK capability may be silently stripped by the store's converter.
expect([...(entry?.capabilities as string[])].sort()).toEqual([...ModelCapabilitySchema.options].sort())
// The entry written by the extension must satisfy the real schema that
// the SDK's writeModelsFileSync enforces.
expect(() => StoredModelEntrySchema.parse(entry)).not.toThrow()
})
})
+431 -22
View File
@@ -1,5 +1,15 @@
import {
readModelsFileSync,
resolveModelsRegistryPath,
type StoredModelEntry,
syncStoredProviderRegistration,
writeModelsFileSync,
} from "@cline/core"
import { getGeneratedModelsForProvider, MODEL_COLLECTIONS_BY_PROVIDER_ID } from "@cline/llms"
import { ModelCapabilitySchema } from "@cline/shared"
import { type ApiConfiguration, type ApiProvider, type ModelInfo, openAiModelInfoSafeDefaults } from "@shared/api"
import { ApiFormat } from "@shared/proto/cline/models"
import { Logger } from "@shared/services/Logger"
import { getProviderModelIdKey } from "@shared/storage/provider-keys"
import { isSecretKey, isSettingsKey, type SecretKey, type SettingsKey } from "@shared/storage/state-keys"
import { StateManager } from "@/core/storage/StateManager"
@@ -9,14 +19,17 @@ import type {
EffectiveProviderConfig,
Mode,
ModelSelection,
ModelSelectionOverrides,
ProviderConfigChange,
ProviderConfigChangeListener,
ProviderConfigPatch,
ProviderConfigStore,
ProviderId,
ResolvedModelSelection,
} from "./contracts"
import { buildEffectiveProviderConfig } from "./effective-config"
import { applyHostModelInfoOverrides } from "./host-overrides"
import { fromSdkApiFormat, nonNegativeFiniteNumber, positiveFiniteNumber, toSdkApiFormat } from "./model-values"
import { toSdkProviderId } from "./sdk-provider-id"
import { adaptSdkModelInfo } from "./shape-adapter"
@@ -113,7 +126,7 @@ const modelInfoKeysByProvider: Partial<Record<string, ModelInfoKeys>> = {
// provider+mode so that switching between providers that share the same
// `*ModeApiModelId` key does not combine one provider's model id with
// another provider's model info.
const selectionMemory = new Map<string, ModelSelection>()
const selectionMemory = new Map<string, ResolvedModelSelection>()
function providerKey(providerId: ProviderId): string {
return providerId.toString()
@@ -168,11 +181,210 @@ function readProviderSettingsModelId(providerId: ProviderId): string | undefined
return typeof model === "string" && model.trim().length > 0 ? model.trim() : undefined
}
function fallbackModelInfo(modelId: string): ModelInfo {
return { ...openAiModelInfoSafeDefaults, name: modelId }
function sanitizeResolvedModelInfo(modelInfo: ModelInfo): ModelInfo {
const next = { ...modelInfo }
if (positiveFiniteNumber(next.maxTokens) === undefined) delete next.maxTokens
if (nonNegativeFiniteNumber(next.temperature) === undefined) delete next.temperature
return next
}
function readKnownModelInfoForProvider(providerId: ProviderId, modelId: string): ModelInfo | undefined {
function fallbackModelInfo(modelId: string): ModelInfo {
return sanitizeResolvedModelInfo({ ...openAiModelInfoSafeDefaults, name: modelId })
}
function toStoredCapabilities(capabilities: readonly string[] | undefined): StoredModelEntry["capabilities"] | undefined {
if (!capabilities) {
return undefined
}
// Validate against the SDK schema rather than a hardcoded list so new
// capabilities added to ModelCapabilitySchema are never silently stripped.
const next = new Set<NonNullable<StoredModelEntry["capabilities"]>[number]>()
for (const capability of capabilities) {
const parsed = ModelCapabilitySchema.safeParse(capability)
if (parsed.success) {
next.add(parsed.data)
}
}
return next.size > 0 ? [...next] : undefined
}
function toStoredApiFormat(apiFormat: ModelInfo["apiFormat"]): StoredModelEntry["apiFormat"] | undefined {
return toSdkApiFormat(apiFormat)
}
function fromStoredApiFormat(apiFormat: StoredModelEntry["apiFormat"]): ModelInfo["apiFormat"] | undefined {
return fromSdkApiFormat(apiFormat)
}
function readModelsState() {
return readModelsFileSync(resolveModelsRegistryPath(getProviderSettingsManager()))
}
/**
* Normalizes user-authored model metadata at the host/storage boundary.
* Token limits must be positive, prices and temperatures non-negative, and
* unsupported capabilities/formats are omitted. UI sentinels never cross
* this boundary; an object with no meaningful fields becomes undefined.
*/
function normalizeModelSelectionOverrides(overrides: ModelSelectionOverrides | undefined): ModelSelectionOverrides | undefined {
if (!overrides) {
return undefined
}
const maxTokens = positiveFiniteNumber(overrides.maxTokens)
const contextWindow = positiveFiniteNumber(overrides.contextWindow)
const maxInputTokens = positiveFiniteNumber(overrides.maxInputTokens)
const capabilities = toStoredCapabilities(overrides.capabilities)
const inputPrice = nonNegativeFiniteNumber(overrides.inputPrice)
const outputPrice = nonNegativeFiniteNumber(overrides.outputPrice)
const cacheReadsPrice = nonNegativeFiniteNumber(overrides.cacheReadsPrice)
const cacheWritesPrice = nonNegativeFiniteNumber(overrides.cacheWritesPrice)
const temperature = nonNegativeFiniteNumber(overrides.temperature)
const apiFormat = toStoredApiFormat(overrides.apiFormat) !== undefined ? overrides.apiFormat : undefined
const next: ModelSelectionOverrides = {
...(overrides.name !== undefined ? { name: overrides.name } : {}),
...(maxTokens !== undefined ? { maxTokens } : {}),
...(contextWindow !== undefined ? { contextWindow } : {}),
...(maxInputTokens !== undefined ? { maxInputTokens } : {}),
...(capabilities !== undefined ? { capabilities } : {}),
...(overrides.supportsVision !== undefined ? { supportsVision: overrides.supportsVision } : {}),
...(overrides.supportsAttachments !== undefined ? { supportsAttachments: overrides.supportsAttachments } : {}),
...(overrides.supportsReasoning !== undefined ? { supportsReasoning: overrides.supportsReasoning } : {}),
...(inputPrice !== undefined ? { inputPrice } : {}),
...(outputPrice !== undefined ? { outputPrice } : {}),
...(cacheReadsPrice !== undefined ? { cacheReadsPrice } : {}),
...(cacheWritesPrice !== undefined ? { cacheWritesPrice } : {}),
...(temperature !== undefined ? { temperature } : {}),
...(apiFormat !== undefined ? { apiFormat } : {}),
...(overrides.isR1FormatRequired !== undefined ? { isR1FormatRequired: overrides.isR1FormatRequired } : {}),
}
return Object.keys(next).length > 0 ? next : undefined
}
function toStoredModelEntry(overrides: ModelSelectionOverrides): StoredModelEntry {
const capabilities = toStoredCapabilities(overrides.capabilities)
const apiFormat = toStoredApiFormat(overrides.apiFormat)
return {
...(overrides.name !== undefined ? { name: overrides.name } : {}),
...(overrides.maxTokens !== undefined ? { maxTokens: overrides.maxTokens } : {}),
...(overrides.contextWindow !== undefined ? { contextWindow: overrides.contextWindow } : {}),
...(overrides.maxInputTokens !== undefined ? { maxInputTokens: overrides.maxInputTokens } : {}),
...(capabilities !== undefined ? { capabilities } : {}),
...(overrides.supportsVision !== undefined ? { supportsVision: overrides.supportsVision } : {}),
...(overrides.supportsAttachments !== undefined ? { supportsAttachments: overrides.supportsAttachments } : {}),
...(overrides.supportsReasoning !== undefined ? { supportsReasoning: overrides.supportsReasoning } : {}),
...(overrides.inputPrice !== undefined ? { inputPrice: overrides.inputPrice } : {}),
...(overrides.outputPrice !== undefined ? { outputPrice: overrides.outputPrice } : {}),
...(overrides.cacheReadsPrice !== undefined ? { cacheReadsPrice: overrides.cacheReadsPrice } : {}),
...(overrides.cacheWritesPrice !== undefined ? { cacheWritesPrice: overrides.cacheWritesPrice } : {}),
...(overrides.temperature !== undefined ? { temperature: overrides.temperature } : {}),
...(apiFormat !== undefined ? { apiFormat } : {}),
...(overrides.isR1FormatRequired !== undefined ? { isR1FormatRequired: overrides.isR1FormatRequired } : {}),
}
}
function toSelectionOverrides(entry: StoredModelEntry | undefined): ModelSelectionOverrides | undefined {
if (!entry) {
return undefined
}
const apiFormat = fromStoredApiFormat(entry.apiFormat)
return normalizeModelSelectionOverrides({
...(entry.name !== undefined ? { name: entry.name } : {}),
...(entry.maxTokens !== undefined ? { maxTokens: entry.maxTokens } : {}),
...(entry.contextWindow !== undefined ? { contextWindow: entry.contextWindow } : {}),
...(entry.maxInputTokens !== undefined ? { maxInputTokens: entry.maxInputTokens } : {}),
...(entry.capabilities !== undefined ? { capabilities: [...entry.capabilities] } : {}),
...(entry.supportsVision !== undefined ? { supportsVision: entry.supportsVision } : {}),
...(entry.supportsAttachments !== undefined ? { supportsAttachments: entry.supportsAttachments } : {}),
...(entry.supportsReasoning !== undefined ? { supportsReasoning: entry.supportsReasoning } : {}),
...(entry.inputPrice !== undefined ? { inputPrice: entry.inputPrice } : {}),
...(entry.outputPrice !== undefined ? { outputPrice: entry.outputPrice } : {}),
...(entry.cacheReadsPrice !== undefined ? { cacheReadsPrice: entry.cacheReadsPrice } : {}),
...(entry.cacheWritesPrice !== undefined ? { cacheWritesPrice: entry.cacheWritesPrice } : {}),
...(entry.temperature !== undefined ? { temperature: entry.temperature } : {}),
...(apiFormat !== undefined ? { apiFormat } : {}),
...(entry.isR1FormatRequired !== undefined ? { isR1FormatRequired: entry.isR1FormatRequired } : {}),
})
}
function readStoredModelEntry(providerId: ProviderId, modelId: string): { exists: boolean; entry: StoredModelEntry | undefined } {
const models = readModelsState().providers[providerSettingsProviderId(providerId)]?.models
return {
exists: models ? Object.hasOwn(models, modelId) : false,
entry: models?.[modelId],
}
}
function readModelOverrides(providerId: ProviderId, modelId: string): ModelSelectionOverrides | undefined {
return toSelectionOverrides(readStoredModelEntry(providerId, modelId).entry)
}
function writeModelOverrides(providerId: ProviderId, modelId: string, overrides: ModelSelectionOverrides | undefined): void {
const modelsPath = resolveModelsRegistryPath(getProviderSettingsManager())
const state = readModelsFileSync(modelsPath)
const provider = providerSettingsProviderId(providerId)
const providerEntry = state.providers[provider] ?? {}
const nextModels = { ...(providerEntry.models ?? {}) }
const normalizedOverrides = normalizeModelSelectionOverrides(overrides)
const storedEntry = normalizedOverrides ? toStoredModelEntry(normalizedOverrides) : undefined
if (storedEntry && Object.keys(storedEntry).length > 0) {
nextModels[modelId] = storedEntry
} else {
delete nextModels[modelId]
}
const nextProviderEntry = {
...providerEntry,
models: nextModels,
}
writeModelsFileSync(modelsPath, {
...state,
providers: {
...state.providers,
[provider]: nextProviderEntry,
},
})
// ensureCustomProvidersLoadedSync is load-once per path and would no-op
// here; sync the live registry explicitly so this write is visible to new
// sessions without a restart.
syncStoredProviderRegistration(provider, state.providers[provider], nextProviderEntry)
}
function applyModelOverrides(modelInfo: ModelInfo, overrides: ModelSelectionOverrides | undefined): ModelInfo {
if (!overrides) {
return modelInfo
}
const next: ModelInfo = { ...modelInfo }
if (overrides.name !== undefined) next.name = overrides.name
if (overrides.maxTokens !== undefined) next.maxTokens = overrides.maxTokens
if (overrides.contextWindow !== undefined) next.contextWindow = overrides.contextWindow
if (overrides.maxInputTokens !== undefined)
(next as ModelInfo & { maxInputTokens?: number }).maxInputTokens = overrides.maxInputTokens
if (overrides.inputPrice !== undefined) next.inputPrice = overrides.inputPrice
if (overrides.outputPrice !== undefined) next.outputPrice = overrides.outputPrice
if (overrides.cacheReadsPrice !== undefined) next.cacheReadsPrice = overrides.cacheReadsPrice
if (overrides.cacheWritesPrice !== undefined) next.cacheWritesPrice = overrides.cacheWritesPrice
if (overrides.temperature !== undefined) next.temperature = overrides.temperature
if (overrides.apiFormat !== undefined) next.apiFormat = overrides.apiFormat
// Capability arrays are additive fallback flags: they can only enable
// capabilities the base metadata lacks, never disable base capabilities
// (an array authored for one purpose, e.g. prompt-cache, must not strip
// unrelated base flags like vision). Explicit booleans win when both
// representations are present.
if (overrides.capabilities !== undefined) {
if (overrides.capabilities.includes("images")) next.supportsImages = true
if (overrides.capabilities.includes("prompt-cache")) next.supportsPromptCache = true
if (overrides.capabilities.includes("reasoning")) next.supportsReasoning = true
}
if (overrides.supportsVision !== undefined) next.supportsImages = overrides.supportsVision
if (overrides.supportsReasoning !== undefined) next.supportsReasoning = overrides.supportsReasoning
// apiFormat is canonical. The legacy R1 flag remains a compatibility alias
// that forces R1 only when explicitly true.
if (overrides.isR1FormatRequired) next.apiFormat = ApiFormat.R1_CHAT
return next
}
function readBaseModelInfoForProvider(providerId: ProviderId, modelId: string): ModelInfo | undefined {
const sdkProviderId = toSdkProviderId(providerId)
const generatedModelInfo = getGeneratedModelsForProvider(sdkProviderId)[modelId]
if (isModelInfo(generatedModelInfo)) {
@@ -201,17 +413,36 @@ function readKnownModelInfoForProvider(providerId: ProviderId, modelId: string):
return undefined
}
function readSelectionFromProviderSettings(providerId: ProviderId): ModelSelection | undefined {
function resolveSelection(selection: ModelSelection, stateModelInfoHint?: ModelInfo): ResolvedModelSelection {
const overrides = normalizeModelSelectionOverrides(
selection.overrides ?? readModelOverrides(selection.providerId, selection.modelId),
)
// Base resolution order: SDK catalog, then the picker's persisted state
// snapshot (the only accurate data for dynamic-list models the static
// catalog does not know), then provider-safe fallback defaults.
const catalogModelInfo = readBaseModelInfoForProvider(selection.providerId, selection.modelId)
const baseModelInfo = catalogModelInfo ?? stateModelInfoHint ?? fallbackModelInfo(selection.modelId)
const modelInfoSource = catalogModelInfo ? "catalog" : stateModelInfoHint ? "state" : "fallback"
return {
...selection,
overrides,
modelInfoSource,
baseModelInfo,
modelInfo: sanitizeResolvedModelInfo(applyModelOverrides(baseModelInfo, overrides)),
}
}
export function resolveRuntimeModelSelection(providerId: ProviderId, modelId: string): ResolvedModelSelection {
return resolveSelection({ providerId, modelId })
}
function readSelectionFromProviderSettings(providerId: ProviderId): ResolvedModelSelection | undefined {
const modelId = readProviderSettingsModelId(providerId)
if (!modelId) {
return undefined
}
return {
providerId,
modelId,
modelInfo: readKnownModelInfoForProvider(providerId, modelId) ?? fallbackModelInfo(modelId),
}
return resolveSelection({ providerId, modelId })
}
function writeStateKey(key: SecretKey | SettingsKey, value: unknown): void {
@@ -281,6 +512,17 @@ function writeStateFields(providerId: ProviderId, patch: ProviderConfigPatch): v
writeStateKey("clineApiKey", patch.auth?.accessToken)
writeStateKey("clineAccountId", patch.auth?.accountId)
}
// Mirror the Ollama context window to the legacy state key so older
// readers (proto ApiConfiguration, webview display fallback) stay in sync
// with providers.json.
if (provider === "ollama" && "contextWindow" in patch) {
const contextWindow = patch.contextWindow
writeStateKey(
"ollamaApiOptionsCtxNum",
typeof contextWindow === "number" && contextWindow > 0 ? String(contextWindow) : undefined,
)
}
}
function getProviderSettings(providerId: ProviderId): ProviderSettingsRecord {
@@ -330,6 +572,15 @@ function writeProviderSettingsFields(providerId: ProviderId, patch: ProviderConf
}
}
if ("contextWindow" in patch) {
const contextWindow = patch.contextWindow
if (typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0) {
next.contextWindow = Math.floor(contextWindow)
} else {
delete next.contextWindow
}
}
if ("aws" in patch) {
const awsPatch = patch.aws
if (awsPatch === null || awsPatch === undefined) {
@@ -389,13 +640,27 @@ function syncedModes(mode: Mode): Mode[] {
return StateManager.get().getGlobalSettingsKey("planActSeparateModelsSetting") ? [mode] : ["plan", "act"]
}
function writeSelectionToState(providerId: ProviderId, mode: Mode, selection: ModelSelection): void {
function writeSelectionToState(providerId: ProviderId, mode: Mode, selection: ResolvedModelSelection): void {
const updates: Partial<Record<SettingsKey, unknown>> = {}
for (const targetMode of syncedModes(mode)) {
updates[getModelIdKey(providerId, targetMode)] = selection.modelId
const modelInfoKey = getModelInfoKey(providerId, targetMode)
if (modelInfoKey) {
updates[modelInfoKey] = selection.modelInfo
// For hint-eligible providers the snapshot must stay genuine base
// metadata: never persist fabricated fallback data (later reads
// would treat it as authoritative "state" data and shadow live
// catalog lookups), and persist the pre-override base rather than
// the resolved value (a deleted override must not be resurrected
// from a snapshot it was baked into). openai-compatible keeps the
// legacy resolved write — its snapshot is never used as a
// resolution base, and old extension versions still read it after
// a rollback.
if (usesStateModelInfoHint(providerId)) {
updates[modelInfoKey] =
selection.modelInfoSource === "fallback" ? undefined : (selection.baseModelInfo ?? selection.modelInfo)
} else {
updates[modelInfoKey] = selection.modelInfo
}
}
selectionMemory.set(memoryKey(providerId, targetMode), { ...selection, providerId })
}
@@ -404,28 +669,164 @@ function writeSelectionToState(providerId: ProviderId, mode: Mode, selection: Mo
function writeSelectionToProviderSettings(providerId: ProviderId, selection: ModelSelection): void {
const next: ProviderSettingsRecord = { ...getProviderSettings(providerId), model: selection.modelId }
// Prune model metadata that earlier builds may have written to providers.json.
delete next.contextWindow
// Prune model metadata that earlier builds may have written to
// providers.json — except for Ollama, whose contextWindow is a real
// user setting (maps to num_ctx) written by the settings UI.
if (providerKey(providerId) !== "ollama") {
delete next.contextWindow
}
delete next.maxTokens
saveProviderSettings(providerId, next)
}
function readSelectionFromState(providerId: ProviderId, mode: Mode): ModelSelection | undefined {
type LegacyModelInfo = ModelInfo & { maxInputTokens?: number; isR1FormatRequired?: boolean }
type MutableModelSelectionOverrides = { -readonly [Key in keyof ModelSelectionOverrides]: ModelSelectionOverrides[Key] }
function legacyModelInfoToOverrides(modelInfo: LegacyModelInfo, fallback: ModelInfo): ModelSelectionOverrides | undefined {
const fallbackInfo = fallback as LegacyModelInfo
const overrides: MutableModelSelectionOverrides = {}
if (modelInfo.name !== undefined && modelInfo.name !== fallback.name) overrides.name = modelInfo.name
if (modelInfo.maxTokens !== undefined && modelInfo.maxTokens !== fallback.maxTokens) overrides.maxTokens = modelInfo.maxTokens
if (modelInfo.contextWindow !== undefined && modelInfo.contextWindow !== fallback.contextWindow)
overrides.contextWindow = modelInfo.contextWindow
if (modelInfo.maxInputTokens !== undefined && modelInfo.maxInputTokens !== fallbackInfo.maxInputTokens)
overrides.maxInputTokens = modelInfo.maxInputTokens
const supportsVision = modelInfo.supportsImages ?? fallback.supportsImages
if (Boolean(supportsVision) !== Boolean(fallback.supportsImages)) overrides.supportsVision = Boolean(supportsVision)
if (Boolean(modelInfo.supportsReasoning) !== Boolean(fallback.supportsReasoning))
overrides.supportsReasoning = Boolean(modelInfo.supportsReasoning)
if (modelInfo.supportsPromptCache !== fallback.supportsPromptCache) {
const capabilities: string[] = []
if (supportsVision) capabilities.push("images")
if (modelInfo.supportsPromptCache) capabilities.push("prompt-cache")
overrides.capabilities = capabilities
}
if (modelInfo.inputPrice !== undefined && modelInfo.inputPrice !== fallback.inputPrice)
overrides.inputPrice = modelInfo.inputPrice
if (modelInfo.outputPrice !== undefined && modelInfo.outputPrice !== fallback.outputPrice)
overrides.outputPrice = modelInfo.outputPrice
if (modelInfo.cacheReadsPrice !== undefined && modelInfo.cacheReadsPrice !== fallback.cacheReadsPrice)
overrides.cacheReadsPrice = modelInfo.cacheReadsPrice
if (modelInfo.cacheWritesPrice !== undefined && modelInfo.cacheWritesPrice !== fallback.cacheWritesPrice)
overrides.cacheWritesPrice = modelInfo.cacheWritesPrice
if (modelInfo.temperature !== undefined && modelInfo.temperature !== fallback.temperature)
overrides.temperature = modelInfo.temperature
if (modelInfo.apiFormat !== undefined && modelInfo.apiFormat !== fallback.apiFormat) overrides.apiFormat = modelInfo.apiFormat
if (modelInfo.isR1FormatRequired === true && fallbackInfo.isR1FormatRequired !== true) overrides.isR1FormatRequired = true
return normalizeModelSelectionOverrides(overrides)
}
// Providers/models whose legacy-state migration has already been attempted in
// this process. The migration runs from the read path, so it must be cheap on
// repeat reads and must never run more than once per selection — including
// when the legacy snapshot diffs to an empty override set and nothing is
// written.
const attemptedLegacyMigrations = new Set<string>()
function migrateLegacyModelOverridesIfNeeded(providerId: ProviderId, modelId: string, modelInfo: ModelInfo): void {
if (providerSettingsProviderId(providerId) !== "openai-compatible") {
return
}
const migrationKey = `${providerId}:${modelId}`
if (attemptedLegacyMigrations.has(migrationKey)) {
return
}
attemptedLegacyMigrations.add(migrationKey)
if (readStoredModelEntry(providerId, modelId).exists) {
return
}
if (readBaseModelInfoForProvider(providerId, modelId) !== undefined) {
return
}
const overrides = legacyModelInfoToOverrides(modelInfo as LegacyModelInfo, fallbackModelInfo(modelId))
if (overrides) {
try {
writeModelOverrides(providerId, modelId, overrides)
} catch (error) {
// The migration is best-effort and runs inside read paths; a
// failed write (read-only fs, disk full) must not fail read RPCs.
Logger.warn(
`[ModelCatalog] Failed to migrate legacy overrides for provider=${providerId} model=${modelId}: ${
error instanceof Error ? error.message : String(error)
}`,
)
}
}
}
/**
* The picker writes the live model metadata to the mode-specific
* `*ModeModelInfo` state key before committing. When the state still refers to
* the model being resolved, that snapshot is the best available base for
* dynamic-list models the static catalog does not know.
*
* openai-compatible is excluded: its legacy state snapshot is user-authored
* metadata that {@link migrateLegacyModelOverridesIfNeeded} converts into
* models.json overrides, which are the source of truth there. Feeding the
* snapshot back as a base would resurrect overrides the user deleted.
*/
function usesStateModelInfoHint(providerId: ProviderId): boolean {
return providerSettingsProviderId(providerId) !== "openai-compatible"
}
/**
* Pickers write `{ ...openAiModelInfoSafeDefaults, name: modelId }` to the
* state key when the user selects an id the live model list does not (yet)
* contain. Such a snapshot carries no real information and must not be
* treated as authoritative "state" metadata.
*/
function isSafeDefaultsSnapshot(modelInfo: ModelInfo, modelId: string): boolean {
const fabricated: Record<string, unknown> = { ...openAiModelInfoSafeDefaults, name: modelId }
const snapshot = modelInfo as unknown as Record<string, unknown>
for (const key of new Set([...Object.keys(fabricated), ...Object.keys(snapshot)])) {
if (fabricated[key] !== snapshot[key]) {
return false
}
}
return true
}
function readStateModelInfoHint(providerId: ProviderId, mode: Mode, modelId: string): ModelInfo | undefined {
if (!usesStateModelInfoHint(providerId)) {
return undefined
}
const modelInfoKey = getModelInfoKey(providerId, mode)
if (!modelInfoKey) {
return undefined
}
const apiConfiguration = StateManager.get().getApiConfiguration()
const stateModelId = apiConfiguration[getModelIdKey(providerId, mode)]
const stateModelInfo = apiConfiguration[modelInfoKey]
return stateModelId === modelId && isModelInfo(stateModelInfo) && !isSafeDefaultsSnapshot(stateModelInfo, modelId)
? stateModelInfo
: undefined
}
function readSelectionFromState(providerId: ProviderId, mode: Mode): ResolvedModelSelection | undefined {
const apiConfiguration = StateManager.get().getApiConfiguration()
const modelId = apiConfiguration[getModelIdKey(providerId, mode)]
const modelInfoKey = getModelInfoKey(providerId, mode)
const rememberedSelection = selectionMemory.get(memoryKey(providerId, mode))
const providerSettingsSelection = readSelectionFromProviderSettings(providerId)
if (modelInfoKey) {
const modelInfo = apiConfiguration[modelInfoKey]
if (typeof modelId !== "string" || modelId.length === 0 || !isModelInfo(modelInfo)) {
return providerSettingsSelection
if (typeof modelId !== "string" || modelId.length === 0) {
return readSelectionFromProviderSettings(providerId)
}
return { providerId, modelId, modelInfo }
// The mode-specific model id alone identifies the selection; the state
// modelInfo snapshot is optional input for legacy migration and, for
// dynamic-list providers, the base-metadata hint. Fallback-tier commits
// intentionally leave it unset.
if (isModelInfo(modelInfo)) {
migrateLegacyModelOverridesIfNeeded(providerId, modelId, modelInfo)
}
return resolveSelection({ providerId, modelId }, readStateModelInfoHint(providerId, mode, modelId))
}
const providerSettingsSelection = readSelectionFromProviderSettings(providerId)
const activeProvider = mode === "plan" ? apiConfiguration.planModeApiProvider : apiConfiguration.actModeApiProvider
const provider = providerForStorage(providerId)
if (activeProvider !== provider) {
@@ -464,7 +865,7 @@ export function createProviderConfigStore(): ProviderConfigStore {
return { ...buildEffectiveProviderConfig(providerId) }
},
readSelection(providerId: ProviderId, mode: Mode): ModelSelection | undefined {
readSelection(providerId: ProviderId, mode: Mode): ResolvedModelSelection | undefined {
return readSelectionFromState(providerId, mode)
},
@@ -482,9 +883,17 @@ export function createProviderConfigStore(): ProviderConfigStore {
},
commitSelection(providerId: ProviderId, mode: Mode, selection: ModelSelection): void {
writeSelectionToState(providerId, mode, selection)
writeSelectionToProviderSettings(providerId, selection)
emit({ kind: "selection", providerId, mode, selection })
if (selection.overrides !== undefined) {
writeModelOverrides(providerId, selection.modelId, selection.overrides)
}
// Read the picker-written state snapshot before writeSelectionToState
// replaces it, so dynamic-list models keep their live metadata instead
// of being re-resolved to fallback defaults.
const stateModelInfoHint = readStateModelInfoHint(providerId, mode, selection.modelId)
const resolvedSelection = resolveSelection({ providerId, modelId: selection.modelId }, stateModelInfoHint)
writeSelectionToState(providerId, mode, resolvedSelection)
emit({ kind: "selection", providerId, mode, selection: resolvedSelection })
},
}
}
@@ -0,0 +1,36 @@
import { readModelsFileSync, writeModelsFileSync } from "@cline/core"
import { describe, expect, it } from "vitest"
type StoredModelsFile = ReturnType<typeof readModelsFileSync>
const firstPath = "/tmp/first-models.json"
const secondPath = "/tmp/second-models.json"
const storedModelsFile = (): StoredModelsFile => ({
version: 1,
providers: {
"openai-compatible": {
models: {
custom: { name: "Custom", capabilities: ["tools"] },
},
},
},
})
describe("cline core model-file test stub", () => {
it("isolates paths and returns defensive copies", () => {
const input = storedModelsFile()
writeModelsFileSync(firstPath, input)
input.providers["openai-compatible"].models!.custom.name = "mutated input"
const firstRead = readModelsFileSync(firstPath)
firstRead.providers["openai-compatible"].models!.custom.name = "mutated read"
expect(readModelsFileSync(firstPath)).toEqual(storedModelsFile())
expect(readModelsFileSync(secondPath)).toEqual({ version: 1, providers: {} })
})
it("cannot observe model writes from the preceding test", () => {
expect(readModelsFileSync(firstPath)).toEqual({ version: 1, providers: {} })
})
})
@@ -0,0 +1,85 @@
import type { ModelInfo } from "@shared/api"
import { ModelOverrides } from "@shared/proto/cline/models"
/**
* Domain shape of user-authored per-model metadata overrides, shared by the
* webview (commit path) and the host (read/commit handlers). Mirrors the
* `ModelOverrides` proto message.
*
* Semantics (enforced host-side in the provider config store):
* - `capabilities` accepts only SDK `ModelCapability` values; unknown
* strings are silently dropped. The array is additive over the base
* metadata; the explicit `supports*` booleans win when both are present.
* - `isR1FormatRequired` is a legacy alias that forces the R1 chat format
* only when true; `apiFormat` is canonical.
* - Invalid numbers (non-positive token limits, negative prices or
* temperature, non-finite values) are silently discarded, not rejected.
*
* When committing a selection, the overrides value is tri-state: `undefined`
* preserves the model's stored overrides, an explicitly empty object clears
* them, and a non-empty object replaces them wholesale (no per-field merge).
*/
export interface ProviderModelOverrides {
name?: string
maxTokens?: number
contextWindow?: number
maxInputTokens?: number
capabilities?: readonly string[]
supportsVision?: boolean
supportsAttachments?: boolean
supportsReasoning?: boolean
inputPrice?: number
outputPrice?: number
cacheReadsPrice?: number
cacheWritesPrice?: number
temperature?: number
apiFormat?: ModelInfo["apiFormat"]
isR1FormatRequired?: boolean
}
export function toProtobufModelOverrides(overrides: ProviderModelOverrides): ModelOverrides {
return ModelOverrides.create({
name: overrides.name,
maxTokens: overrides.maxTokens,
contextWindow: overrides.contextWindow,
maxInputTokens: overrides.maxInputTokens,
capabilities: overrides.capabilities ? [...overrides.capabilities] : [],
supportsVision: overrides.supportsVision,
supportsAttachments: overrides.supportsAttachments,
supportsReasoning: overrides.supportsReasoning,
inputPrice: overrides.inputPrice,
outputPrice: overrides.outputPrice,
cacheReadsPrice: overrides.cacheReadsPrice,
cacheWritesPrice: overrides.cacheWritesPrice,
temperature: overrides.temperature,
apiFormat: overrides.apiFormat,
isR1FormatRequired: overrides.isR1FormatRequired,
})
}
/**
* Preserves the proto tri-state: `undefined` stays `undefined` (no override
* payload), and an empty message becomes an empty object (explicit clear).
*/
export function fromProtobufModelOverrides(overrides: ModelOverrides | undefined): ProviderModelOverrides | undefined {
if (!overrides) {
return undefined
}
return {
...(overrides.name !== undefined ? { name: overrides.name } : {}),
...(overrides.maxTokens !== undefined ? { maxTokens: overrides.maxTokens } : {}),
...(overrides.contextWindow !== undefined ? { contextWindow: overrides.contextWindow } : {}),
...(overrides.maxInputTokens !== undefined ? { maxInputTokens: overrides.maxInputTokens } : {}),
...(overrides.capabilities.length > 0 ? { capabilities: [...overrides.capabilities] } : {}),
...(overrides.supportsVision !== undefined ? { supportsVision: overrides.supportsVision } : {}),
...(overrides.supportsAttachments !== undefined ? { supportsAttachments: overrides.supportsAttachments } : {}),
...(overrides.supportsReasoning !== undefined ? { supportsReasoning: overrides.supportsReasoning } : {}),
...(overrides.inputPrice !== undefined ? { inputPrice: overrides.inputPrice } : {}),
...(overrides.outputPrice !== undefined ? { outputPrice: overrides.outputPrice } : {}),
...(overrides.cacheReadsPrice !== undefined ? { cacheReadsPrice: overrides.cacheReadsPrice } : {}),
...(overrides.cacheWritesPrice !== undefined ? { cacheWritesPrice: overrides.cacheWritesPrice } : {}),
...(overrides.temperature !== undefined ? { temperature: overrides.temperature } : {}),
...(overrides.apiFormat !== undefined ? { apiFormat: overrides.apiFormat } : {}),
...(overrides.isR1FormatRequired !== undefined ? { isR1FormatRequired: overrides.isR1FormatRequired } : {}),
}
}
+9 -1
View File
@@ -32,8 +32,9 @@
// Importing the real package here is safe: the preload runs before any test
// file, so this is the only point the real module is linked, and we only read
// its export *names*, never its behavior (the mock shadows it everywhere tests look).
import { vi as bunVi, mock } from "bun:test"
import { beforeEach as bunBeforeEach, vi as bunVi, mock } from "bun:test"
import * as realClineCore from "@cline/core"
import * as LlmsModels from "@cline/llms"
import * as clineCoreStub from "./cline-core-vitest-stub"
import * as vscodeStub from "./vscode-vitest-stub"
@@ -43,6 +44,13 @@ for (const name of Object.keys(realClineCore)) {
}
Object.assign(clineCoreNamespace, clineCoreStub)
bunBeforeEach(() => {
clineCoreStub.resetModelsFileState()
// The stub's syncStoredProviderRegistration mutates the real shared
// @cline/llms registry; reset it so registrations never leak across tests.
LlmsModels.resetRegistry()
})
mock.module("@cline/core", () => clineCoreNamespace)
// `vscode`: the stub provides both named exports (Position, Uri, …) and a
@@ -13,6 +13,54 @@ export interface StartSessionResult {
export const MAX_COMMAND_OUTPUT_CHARS = 200_000
export interface StoredModelEntry {
id?: string
name?: string
maxTokens?: number
contextWindow?: number
maxInputTokens?: number
capabilities?: string[]
[key: string]: unknown
}
export interface StoredModelsFile {
version: 1
providers: Record<string, { models?: Record<string, StoredModelEntry> }>
}
const modelsFiles = new Map<string, StoredModelsFile>()
function cloneModelsFile(modelsFile: StoredModelsFile): StoredModelsFile {
return structuredClone(modelsFile)
}
export function resetModelsFileState(): void {
modelsFiles.clear()
}
export function readModelsFileSync(filePath: string): StoredModelsFile {
return cloneModelsFile(modelsFiles.get(filePath) ?? { version: 1, providers: {} })
}
export function writeModelsFileSync(filePath: string, next: StoredModelsFile): void {
modelsFiles.set(filePath, cloneModelsFile(next))
}
export function resolveModelsRegistryPath(): string {
return "/tmp/models.json"
}
export function ensureCustomProvidersLoadedSync(): void {}
// Real implementation re-exported from the sdk source (same pattern as the
// apply-patch executors below) so store writes are reflected in the live
// @cline/llms registry exactly as in production. Tests that touch it must
// reset the registry (LlmsModels.resetRegistry()) between tests.
export {
StoredModelEntrySchema,
syncStoredProviderRegistration,
} from "../../../../sdk/packages/core/src/services/providers/local-provider-registry"
export type GlobalCompactionStrategy = "basic" | "agentic"
export function readCompactionStrategyGlobally(): GlobalCompactionStrategy {
+15
View File
@@ -0,0 +1,15 @@
// Under vitest, `@cline/core` is aliased to src/test/cline-core-vitest-stub.ts
// (see vitest.config.ts), which holds models.json state in memory and exposes
// the stub-only `resetModelsFileState` — hence the cast below.
import * as ClineCore from "@cline/core"
import { resetRegistry } from "@cline/llms"
import { beforeEach } from "vitest"
const { resetModelsFileState } = ClineCore as typeof ClineCore & { resetModelsFileState(): void }
beforeEach(() => {
resetModelsFileState()
// The stub's syncStoredProviderRegistration mutates the real shared
// @cline/llms registry; reset it so registrations never leak across tests.
resetRegistry()
})
+1
View File
@@ -23,6 +23,7 @@ export default defineConfig({
"src/core/controller/models/__tests__/refreshGroqModels.test.ts",
],
environment: "node",
setupFiles: ["./src/test/vitest-setup.ts"],
// Several suites lazily `await import()` their subject inside the first test
// (needed so vi.mock factories apply first). That import pulls in heavy
// workspace packages (@cline/core/@cline/llms/@cline/shared), and on loaded
@@ -471,12 +471,10 @@ const OnboardingViewContent = ({ onboardingModels }: { onboardingModels: Onboard
commitSelection("plan", {
providerId: "cline",
modelId: selectedModelId,
modelInfo: selectedModelInfo,
}),
commitSelection("act", {
providerId: "cline",
modelId: selectedModelId,
modelInfo: selectedModelInfo,
}),
])
@@ -105,11 +105,6 @@ describe("ClineModelPicker", () => {
expect(mocks.commitSelection).toHaveBeenCalledWith("act", {
providerId: "cline",
modelId: "cline-next",
modelInfo: {
name: "Cline Next",
supportsPromptCache: true,
contextWindow: 128_000,
},
})
})
@@ -218,7 +218,6 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({ isPopup, currentMod
void commitSelection(currentMode, {
providerId: "cline",
modelId: newModelId,
modelInfo,
}).catch((err) => console.error("Failed to commit Cline model selection:", err))
void handleModeFieldsChange(
@@ -77,7 +77,7 @@ const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({ isPopup,
currentMode,
)
void commitSelection(currentMode, { providerId: "openrouter", modelId: newModelId, modelInfo }).catch((err) =>
void commitSelection(currentMode, { providerId: "openrouter", modelId: newModelId }).catch((err) =>
console.error("Failed to commit OpenRouter model selection:", err),
)
}
@@ -97,7 +97,6 @@ describe("GenericProviderSettings", () => {
expect(commitSelection).toHaveBeenCalledWith("act", {
providerId: "deepseek",
modelId: "deepseek-reasoner",
modelInfo: { name: "DeepSeek Reasoner", supportsPromptCache: true, contextWindow: 128_000, supportsReasoning: true },
})
expect(useProviderModels).toHaveBeenCalledWith("deepseek")
expect(useProviderConfig).toHaveBeenCalledWith("deepseek")
@@ -4,7 +4,6 @@ import { Mode } from "@shared/storage/types"
import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useInterval } from "react-use"
import UseCustomPromptCheckbox from "@/components/settings/UseCustomPromptCheckbox"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useProviderConfig } from "@/hooks/useProviderConfig"
import { useProviderModelSelection } from "@/hooks/useProviderModelSelection"
@@ -36,13 +35,15 @@ export const OllamaProvider = ({ showModelOptions, isPopup, currentMode }: Ollam
const [ollamaModels, setOllamaModels] = useState<string[]>([])
const ollamaBaseUrl = config?.baseUrl ?? apiConfiguration?.ollamaBaseUrl
// providers.json (config.contextWindow) is the source of truth; the legacy
// apiConfiguration string is a migration fallback.
const ollamaNumCtx = config?.contextWindow || Number.parseInt(apiConfiguration?.ollamaApiOptionsCtxNum || "", 10)
const ollamaModelInfo = useMemo(() => {
const contextWindow = Number.parseInt(apiConfiguration?.ollamaApiOptionsCtxNum || "", 10)
return {
...openAiModelInfoSafeDefaults,
...(Number.isFinite(contextWindow) && contextWindow > 0 ? { contextWindow } : {}),
...(Number.isFinite(ollamaNumCtx) && ollamaNumCtx > 0 ? { contextWindow: ollamaNumCtx } : {}),
}
}, [apiConfiguration?.ollamaApiOptionsCtxNum])
}, [ollamaNumCtx])
const ollamaModelInfoById = useMemo(
() => Object.fromEntries(ollamaModels.map((modelId) => [modelId, { ...ollamaModelInfo, name: modelId }])),
[ollamaModelInfo, ollamaModels],
@@ -137,27 +138,45 @@ export const OllamaProvider = ({ showModelOptions, isPopup, currentMode }: Ollam
</p>
)}
<DebouncedTextField
initialValue={apiConfiguration?.ollamaApiOptionsCtxNum || "32768"}
onChange={(v) => {
handleFieldChange("ollamaApiOptionsCtxNum", v || undefined)
{/* Render only after the provider config RPC has resolved: the
debounced input fires onChange for its initial value shortly
after mount, so mounting before `config` loads would persist
the 32768 fallback over a value saved in providers.json. */}
{config !== undefined && (
<DebouncedTextField
initialValue={Number.isFinite(ollamaNumCtx) && ollamaNumCtx > 0 ? String(ollamaNumCtx) : ""}
onChange={(v) => {
const contextWindow = Number.parseInt(v, 10)
const numCtx = Number.isFinite(contextWindow) && contextWindow > 0 ? contextWindow : undefined
// The debounced input also fires for its initial value and
// external prop syncs — only persist actual changes.
const currentNumCtx = Number.isFinite(ollamaNumCtx) && ollamaNumCtx > 0 ? ollamaNumCtx : undefined
if (numCtx === currentNumCtx) {
return
}
// Persist to providers.json (`contextWindow`); the store
// mirrors the value to the legacy state key for older
// readers. Zero clears the setting.
void write({ contextWindow: numCtx ?? 0 }).catch((error) =>
console.error("Failed to update Ollama context window:", error),
)
const contextWindow = Number.parseInt(v, 10)
if (selectedModel.modelId) {
void commitModelSelection({
modelId: selectedModel.modelId,
modelInfo: {
...openAiModelInfoSafeDefaults,
name: selectedModel.modelId,
...(Number.isFinite(contextWindow) && contextWindow > 0 ? { contextWindow } : {}),
},
}).catch((error) => console.error("Failed to update Ollama context window:", error))
}
}}
placeholder={"e.g. 32768"}
style={{ width: "100%" }}>
<span className="font-semibold">Model Context Window</span>
</DebouncedTextField>
if (selectedModel.modelId) {
void commitModelSelection({
modelId: selectedModel.modelId,
modelInfo: {
...openAiModelInfoSafeDefaults,
name: selectedModel.modelId,
...(numCtx ? { contextWindow: numCtx } : {}),
},
}).catch((error) => console.error("Failed to update Ollama context window:", error))
}
}}
placeholder={"Default: 32768"}
style={{ width: "100%" }}>
<span className="font-semibold">Model Context Window</span>
</DebouncedTextField>
)}
{showModelOptions && (
<>
@@ -180,8 +199,6 @@ export const OllamaProvider = ({ showModelOptions, isPopup, currentMode }: Ollam
</>
)}
<UseCustomPromptCheckbox providerId="ollama" />
<p
style={{
fontSize: "12px",
@@ -0,0 +1,517 @@
import { ApiFormat } from "@shared/proto/cline/models"
import { act, fireEvent, render, screen } from "@testing-library/react"
import type { ChangeEventHandler, ReactNode } from "react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { OpenAICompatibleProvider } from "./OpenAICompatible"
const mocks = vi.hoisted(() => ({
commitSelection: vi.fn(),
handleFieldChange: vi.fn(),
handleModeFieldChange: vi.fn(),
refreshOpenAiModels: vi.fn(),
useDynamicProviderSelection: vi.fn(),
useExtensionState: vi.fn(),
useProviderConfig: vi.fn(),
write: vi.fn(),
}))
vi.mock("@/context/ExtensionStateContext", () => ({
useExtensionState: mocks.useExtensionState,
}))
vi.mock("@/hooks/useDynamicProviderSelection", () => ({
useDynamicProviderSelection: mocks.useDynamicProviderSelection,
}))
vi.mock("@/hooks/useProviderConfig", () => ({
fromProtobufProviderModelOverrides: (overrides: Record<string, unknown> | undefined) =>
overrides ? { ...overrides } : undefined,
useProviderConfig: mocks.useProviderConfig,
}))
vi.mock("@/services/grpc-client", () => ({
ModelsServiceClient: {
refreshOpenAiModels: mocks.refreshOpenAiModels,
},
}))
vi.mock("../utils/useApiConfigurationHandlers", () => ({
useApiConfigurationHandlers: () => ({
handleFieldChange: mocks.handleFieldChange,
handleModeFieldChange: mocks.handleModeFieldChange,
}),
}))
vi.mock("@radix-ui/react-tooltip", () => ({
TooltipContent: ({ children }: { children?: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children?: ReactNode }) => <>{children}</>,
}))
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children?: ReactNode }) => <>{children}</>,
}))
vi.mock("@vscode/webview-ui-toolkit/react", () => ({
VSCodeButton: ({ children, disabled, onClick }: { children?: ReactNode; disabled?: boolean; onClick?: () => void }) => (
<button disabled={disabled} onClick={onClick} type="button">
{children}
</button>
),
VSCodeCheckbox: ({
checked,
children,
onChange,
}: {
checked?: boolean
children?: ReactNode
onChange?: ChangeEventHandler<HTMLInputElement>
}) => (
<label>
<input checked={checked} onChange={onChange} type="checkbox" />
{children}
</label>
),
}))
vi.mock("../common/ApiKeyField", () => ({
ApiKeyField: ({
initialValue,
onChange,
providerName,
}: {
initialValue?: string
onChange: (value: string) => void
providerName: string
}) => (
<input aria-label={`${providerName} API key`} onChange={(event) => onChange(event.target.value)} value={initialValue} />
),
}))
vi.mock("../common/BaseUrlField", () => ({
BaseUrlField: ({
disabled,
initialValue,
label,
onChange,
}: {
disabled?: boolean
initialValue?: string
label: string
onChange: (value: string) => void
}) => (
<label>
{label}
<input
aria-label={label}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
value={initialValue ?? ""}
/>
</label>
),
}))
vi.mock("../common/DebouncedTextField", () => ({
DebouncedTextField: ({
children,
disabled,
initialValue,
onChange,
placeholder,
}: {
children?: ReactNode
disabled?: boolean
initialValue?: string
onChange: (value: string) => void
placeholder?: string
}) => (
<label>
{children}
<input
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
value={initialValue ?? ""}
/>
</label>
),
}))
vi.mock("../common/ModelInfoView", () => ({ ModelInfoView: () => null }))
vi.mock("../ReasoningEffortSelector", () => ({ default: () => null }))
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise
})
return { promise, resolve }
}
function renderProvider() {
return render(<OpenAICompatibleProvider currentMode="act" providerId="custom-openai" showModelOptions={false} />)
}
function setCommittedSelection(overrides: Record<string, unknown>, modelInfo: Record<string, unknown> = {}) {
mocks.useProviderConfig.mockReturnValue({
config: {
actSelection: {
providerId: "custom-openai",
modelId: "custom-model",
modelInfo: {
contextWindow: 128_000,
inputPrice: 0,
maxTokens: -1,
outputPrice: 0,
temperature: 0,
tiers: [],
...modelInfo,
},
overrides,
},
apiKeyLength: 12,
baseUrl: "http://localhost:1234/v1",
headers: {},
providerId: "custom-openai",
},
commitSelection: mocks.commitSelection,
write: mocks.write,
})
}
describe("OpenAICompatibleProvider", () => {
beforeEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
mocks.commitSelection.mockResolvedValue(undefined)
mocks.write.mockResolvedValue(undefined)
mocks.refreshOpenAiModels.mockResolvedValue({ values: [] })
mocks.useExtensionState.mockReturnValue({
apiConfiguration: { azureApiVersion: "2025-04-01-preview", azureIdentity: false },
remoteConfigSettings: undefined,
})
mocks.useDynamicProviderSelection.mockReturnValue({
selectedModelId: "custom-model",
selectedModelInfo: {
contextWindow: 128_000,
inputPrice: 0,
maxTokens: -1,
outputPrice: 0,
temperature: 0,
},
})
mocks.useProviderConfig.mockReturnValue({
config: {
apiKeyLength: 12,
baseUrl: "http://localhost:1234/v1",
headers: {},
providerId: "custom-openai",
},
commitSelection: mocks.commitSelection,
write: mocks.write,
})
})
it("refreshes keyless endpoints and displays only the saved-key mask", async () => {
renderProvider()
await act(async () => {})
expect(mocks.refreshOpenAiModels).toHaveBeenCalledWith(
expect.objectContaining({ baseUrl: "http://localhost:1234/v1", apiKey: "" }),
)
expect(screen.getByLabelText("OpenAI Compatible API key")).toHaveValue("••••••••••••")
})
it("writes a newly entered API key without echoing a stored key into config", async () => {
renderProvider()
await act(async () => {})
fireEvent.change(screen.getByLabelText("OpenAI Compatible API key"), { target: { value: "new-secret" } })
expect(mocks.write).toHaveBeenCalledWith({ apiKey: "new-secret" })
})
it("commits ordinary model selections by ID only", async () => {
mocks.refreshOpenAiModels.mockResolvedValue({ values: ["listed-model"] })
renderProvider()
await act(async () => {})
fireEvent.change(screen.getByLabelText("Model ID"), { target: { value: "listed-model" } })
expect(mocks.commitSelection).toHaveBeenCalledWith("act", {
providerId: "custom-openai",
modelId: "listed-model",
})
})
it("persists only the edited vision field while preserving existing overrides", async () => {
setCommittedSelection({
apiFormat: ApiFormat.OPENAI_RESPONSES,
cacheReadsPrice: 0.5,
cacheWritesPrice: 0.75,
capabilities: ["tools", "streaming"],
outputPrice: 2,
})
renderProvider()
await act(async () => {})
fireEvent.click(screen.getByText("Model Configuration"))
fireEvent.click(screen.getByRole("checkbox", { name: "Supports Images" }))
expect(mocks.commitSelection).toHaveBeenCalledWith("act", {
providerId: "custom-openai",
modelId: "custom-model",
overrides: {
apiFormat: ApiFormat.OPENAI_RESPONSES,
cacheReadsPrice: 0.5,
cacheWritesPrice: 0.75,
capabilities: ["tools", "streaming"],
outputPrice: 2,
supportsVision: true,
},
})
})
it("restores the R1 checkbox from authored override readback", async () => {
setCommittedSelection({ isR1FormatRequired: true })
renderProvider()
await act(async () => {})
fireEvent.click(screen.getByText("Model Configuration"))
expect(screen.getByRole("checkbox", { name: "Enable R1 messages format" })).toBeChecked()
})
it("restores the R1 checkbox from canonical resolved apiFormat", async () => {
setCommittedSelection({}, { apiFormat: ApiFormat.R1_CHAT })
renderProvider()
await act(async () => {})
fireEvent.click(screen.getByText("Model Configuration"))
expect(screen.getByRole("checkbox", { name: "Enable R1 messages format" })).toBeChecked()
})
it("persists the R1 checkbox as one explicit override", async () => {
renderProvider()
await act(async () => {})
fireEvent.click(screen.getByText("Model Configuration"))
fireEvent.click(screen.getByRole("checkbox", { name: "Enable R1 messages format" }))
expect(mocks.commitSelection).toHaveBeenCalledWith("act", {
providerId: "custom-openai",
modelId: "custom-model",
overrides: { isR1FormatRequired: true },
})
})
it("persists a temperature edit without adding resolved defaults", async () => {
renderProvider()
await act(async () => {})
fireEvent.click(screen.getByText("Model Configuration"))
fireEvent.change(screen.getByLabelText("Temperature"), { target: { value: "0.25" } })
expect(mocks.commitSelection).toHaveBeenCalledWith("act", {
providerId: "custom-openai",
modelId: "custom-model",
overrides: { temperature: 0.25 },
})
})
it.each([
["Context Window Size", "contextWindow", "64000", 64_000],
["Max Output Tokens", "maxTokens", "4096", 4_096],
["Output Price / 1M tokens", "outputPrice", "2.5", 2.5],
] as const)("maps %s only to the %s override", async (label, key, input, expected) => {
renderProvider()
await act(async () => {})
fireEvent.click(screen.getByText("Model Configuration"))
fireEvent.change(screen.getByLabelText(label), { target: { value: input } })
expect(mocks.commitSelection).toHaveBeenCalledWith("act", {
providerId: "custom-openai",
modelId: "custom-model",
overrides: { [key]: expected },
})
})
it("clears one override while preserving unrelated fields", async () => {
setCommittedSelection({
apiFormat: ApiFormat.OPENAI_RESPONSES,
capabilities: ["tools", "streaming"],
temperature: 0.4,
})
renderProvider()
await act(async () => {})
fireEvent.click(screen.getByText("Model Configuration"))
fireEvent.change(screen.getByLabelText("Temperature"), { target: { value: "" } })
expect(mocks.commitSelection).toHaveBeenCalledWith("act", {
providerId: "custom-openai",
modelId: "custom-model",
overrides: {
apiFormat: ApiFormat.OPENAI_RESPONSES,
capabilities: ["tools", "streaming"],
},
})
})
it("preserves apiFormat while editing pricing", async () => {
setCommittedSelection({
apiFormat: ApiFormat.OPENAI_RESPONSES,
capabilities: ["tools"],
})
renderProvider()
await act(async () => {})
fireEvent.click(screen.getByText("Model Configuration"))
fireEvent.change(screen.getByLabelText("Input Price / 1M tokens"), { target: { value: "1.25" } })
expect(mocks.commitSelection).toHaveBeenCalledWith("act", {
providerId: "custom-openai",
modelId: "custom-model",
overrides: {
apiFormat: ApiFormat.OPENAI_RESPONSES,
capabilities: ["tools"],
inputPrice: 1.25,
},
})
})
it("sends an empty replacement when the final override is cleared", async () => {
setCommittedSelection({ temperature: 0.4 })
renderProvider()
await act(async () => {})
fireEvent.click(screen.getByText("Model Configuration"))
fireEvent.change(screen.getByLabelText("Temperature"), { target: { value: "" } })
expect(mocks.commitSelection).toHaveBeenCalledWith("act", {
providerId: "custom-openai",
modelId: "custom-model",
overrides: {},
})
})
it("shows invalid-number feedback without committing", async () => {
renderProvider()
await act(async () => {})
fireEvent.click(screen.getByText("Model Configuration"))
fireEvent.change(screen.getByLabelText("Max Output Tokens"), { target: { value: "80000o" } })
expect(screen.getByRole("alert")).toHaveTextContent("Max Output Tokens must be a valid number.")
expect(mocks.commitSelection).not.toHaveBeenCalled()
})
it("merges rapid edits using the pending override set", async () => {
renderProvider()
await act(async () => {})
fireEvent.click(screen.getByText("Model Configuration"))
fireEvent.change(screen.getByLabelText("Temperature"), { target: { value: "0.25" } })
fireEvent.change(screen.getByLabelText("Input Price / 1M tokens"), { target: { value: "1.5" } })
expect(mocks.commitSelection).toHaveBeenLastCalledWith("act", {
providerId: "custom-openai",
modelId: "custom-model",
overrides: { inputPrice: 1.5, temperature: 0.25 },
})
})
it("debounces model refreshes triggered by base URL edits", async () => {
vi.useFakeTimers()
renderProvider()
await act(async () => {})
expect(mocks.refreshOpenAiModels).toHaveBeenCalledTimes(1)
fireEvent.change(screen.getByDisplayValue("http://localhost:1234/v1"), {
target: { value: "http://localhost:5678/v1" },
})
expect(mocks.refreshOpenAiModels).toHaveBeenCalledTimes(1)
await act(async () => {
vi.advanceTimersByTime(499)
})
expect(mocks.refreshOpenAiModels).toHaveBeenCalledTimes(1)
await act(async () => {
vi.advanceTimersByTime(1)
})
expect(mocks.refreshOpenAiModels).toHaveBeenCalledTimes(2)
})
it("ignores a stale model-list response", async () => {
vi.useFakeTimers()
const oldRequest = deferred<{ values: string[] }>()
const newRequest = deferred<{ values: string[] }>()
mocks.refreshOpenAiModels.mockReturnValueOnce(oldRequest.promise).mockReturnValueOnce(newRequest.promise)
renderProvider()
fireEvent.change(screen.getByDisplayValue("http://localhost:1234/v1"), {
target: { value: "http://localhost:5678/v1" },
})
await act(async () => {
vi.advanceTimersByTime(500)
})
await act(async () => {
newRequest.resolve({ values: ["new-model"] })
})
expect(screen.getByRole("option", { name: "new-model" })).toBeInTheDocument()
await act(async () => {
oldRequest.resolve({ values: ["stale-model"] })
})
expect(screen.queryByRole("option", { name: "stale-model" })).not.toBeInTheDocument()
expect(screen.getByRole("option", { name: "new-model" })).toBeInTheDocument()
})
it("cancels a pending debounced refresh when unmounted", async () => {
vi.useFakeTimers()
const view = renderProvider()
await act(async () => {})
fireEvent.change(screen.getByDisplayValue("http://localhost:1234/v1"), {
target: { value: "http://localhost:5678/v1" },
})
view.unmount()
await act(async () => {
vi.advanceTimersByTime(500)
})
expect(mocks.refreshOpenAiModels).toHaveBeenCalledTimes(1)
})
it("restores Azure settings and remote-config locks", async () => {
mocks.useExtensionState.mockReturnValue({
apiConfiguration: { azureApiVersion: "2025-04-01-preview", azureIdentity: true },
remoteConfigSettings: {
azureApiVersion: "2025-04-01-preview",
openAiBaseUrl: "https://managed.example/v1",
openAiHeaders: { "x-managed": "true" },
},
})
renderProvider()
await act(async () => {})
expect(screen.getByDisplayValue("http://localhost:1234/v1")).toBeDisabled()
expect(screen.getByRole("button", { name: "Add Header" })).toBeDisabled()
expect(screen.getByLabelText("Set Azure API version")).toBeDisabled()
expect(screen.getByRole("checkbox", { name: "Use Azure Identity Authentication" })).toBeChecked()
})
it("writes editable Azure settings through the legacy handlers", async () => {
renderProvider()
await act(async () => {})
fireEvent.change(screen.getByLabelText("Set Azure API version"), { target: { value: "2026-01-01" } })
fireEvent.click(screen.getByRole("checkbox", { name: "Use Azure Identity Authentication" }))
expect(mocks.handleFieldChange).toHaveBeenCalledWith("azureApiVersion", "2026-01-01")
expect(mocks.handleFieldChange).toHaveBeenCalledWith("azureIdentity", true)
})
})
@@ -1,11 +1,6 @@
import { TooltipContent, TooltipTrigger } from "@radix-ui/react-tooltip"
import {
azureOpenAiDefaultApiVersion,
type ModelInfo,
type OpenAiCompatibleModelInfo,
openAiModelInfoSafeDefaults,
} from "@shared/api"
import { OpenAiModelsRequest } from "@shared/proto/cline/models"
import { azureOpenAiDefaultApiVersion, type OpenAiCompatibleModelInfo, openAiModelInfoSafeDefaults } from "@shared/api"
import { ApiFormat, OpenAiModelsRequest } from "@shared/proto/cline/models"
import { fromProtobufModelInfo } from "@shared/proto-conversions/models/typeConversion"
import type { Mode } from "@shared/storage/types"
import { VSCodeButton, VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
@@ -13,7 +8,7 @@ import { useCallback, useEffect, useRef, useState } from "react"
import { Tooltip } from "@/components/ui/tooltip"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useDynamicProviderSelection } from "@/hooks/useDynamicProviderSelection"
import { useProviderConfig } from "@/hooks/useProviderConfig"
import { fromProtobufProviderModelOverrides, type ProviderModelOverrides, useProviderConfig } from "@/hooks/useProviderConfig"
import { ModelsServiceClient } from "@/services/grpc-client"
import { getAsVar, VSC_DESCRIPTION_FOREGROUND } from "@/utils/vscStyles"
import { ApiKeyField } from "../common/ApiKeyField"
@@ -21,7 +16,6 @@ import { BaseUrlField } from "../common/BaseUrlField"
import { DebouncedTextField } from "../common/DebouncedTextField"
import { ModelInfoView } from "../common/ModelInfoView"
import ReasoningEffortSelector from "../ReasoningEffortSelector"
import { parsePrice } from "../utils/pricingUtils"
import { useApiConfigurationHandlers } from "../utils/useApiConfigurationHandlers"
import { useProviderApiKeyField } from "../utils/useProviderApiKeyField"
@@ -53,6 +47,7 @@ export const OpenAICompatibleProvider = ({
const [availableOpenAiModels, setAvailableOpenAiModels] = useState<string[]>([])
const [isRefreshingOpenAiModels, setIsRefreshingOpenAiModels] = useState(false)
const [openAiModelsError, setOpenAiModelsError] = useState<string | undefined>(undefined)
const [modelFieldErrors, setModelFieldErrors] = useState<Partial<Record<NumericModelOverrideKey, string>>>({})
// Only the built-in "openai" provider stores its API key in the legacy
// ApiConfiguration field; custom providers keep it in their per-provider
// config (available only as a masked length), so there is no plaintext key
@@ -92,34 +87,100 @@ export const OpenAICompatibleProvider = ({
// The Model Configuration section reads/writes the resolved model info.
// OpenAiCompatibleModelInfo only adds optional fields over ModelInfo, so a
// resolved ModelInfo satisfies it structurally.
const openAiModelInfo: OpenAiCompatibleModelInfo = selectedModelInfo
const openAiModelInfo: OpenAiCompatibleModelInfo = selectedModelInfo ?? openAiModelInfoSafeDefaults
const selectedModelOverrides = fromProtobufProviderModelOverrides(committedSelection?.overrides) ?? {}
const selectedModelOverridesRef = useRef<{ modelId: string | undefined; overrides: ProviderModelOverrides }>({
modelId: selectedModelId,
overrides: selectedModelOverrides,
})
// Counts commits whose commit+read-back round-trip has not finished yet.
const pendingCommitsRef = useRef(0)
useEffect(() => {
// Do not reseed the pending-override accumulator from server state
// while commits are in flight: an earlier commit's read-back can land
// after a later local edit, and reseeding from that stale snapshot
// would silently drop the already-committed newer field.
if (pendingCommitsRef.current > 0) {
return
}
selectedModelOverridesRef.current = { modelId: selectedModelId, overrides: selectedModelOverrides }
}, [committedSelection?.overrides, selectedModelId])
const commitOpenAiSelection = useCallback(
(modelId: string, modelInfo = openAiModelInfo ?? openAiModelInfoSafeDefaults) => {
(modelId: string, overrides?: ProviderModelOverrides) => {
if (!modelId.trim()) {
return
}
pendingCommitsRef.current += 1
void commitSelection(currentMode, {
providerId,
modelId,
modelInfo: {
...modelInfo,
supportsPromptCache: modelInfo.supportsPromptCache ?? openAiModelInfoSafeDefaults.supportsPromptCache,
},
}).catch((error) => handleProviderConfigWriteError("model selection", error))
...(overrides !== undefined ? { overrides } : {}),
})
.catch((error) => handleProviderConfigWriteError("model selection", error))
.finally(() => {
pendingCommitsRef.current -= 1
})
},
[commitSelection, currentMode, handleProviderConfigWriteError, openAiModelInfo],
[commitSelection, currentMode, handleProviderConfigWriteError, providerId],
)
const handleOpenAiModelInfoChange = useCallback(
(modelInfo: typeof openAiModelInfoSafeDefaults) => {
if (isOpenAiProvider) {
handleModeFieldChange({ plan: "planModeOpenAiModelInfo", act: "actModeOpenAiModelInfo" }, modelInfo, currentMode)
const updateModelOverride = useCallback(
<K extends keyof ProviderModelOverrides>(key: K, value: ProviderModelOverrides[K] | undefined) => {
const modelId = selectedModelId?.trim()
if (!modelId) {
return
}
commitOpenAiSelection(selectedModelId || "", modelInfo)
const currentOverrides =
selectedModelOverridesRef.current.modelId === modelId ? selectedModelOverridesRef.current.overrides : {}
const nextOverrides = { ...currentOverrides }
if (value === undefined) {
delete nextOverrides[key]
} else {
Object.assign(nextOverrides, { [key]: value })
}
selectedModelOverridesRef.current = { modelId, overrides: nextOverrides }
commitOpenAiSelection(modelId, nextOverrides)
},
[commitOpenAiSelection, currentMode, handleModeFieldChange, isOpenAiProvider, selectedModelId],
[commitOpenAiSelection, selectedModelId],
)
const updateNumericModelOverride = useCallback(
(key: NumericModelOverrideKey, label: string, value: string) => {
const parsed = parseOptionalFiniteNumber(value)
if (!parsed.valid) {
setModelFieldErrors((current) => ({ ...current, [key]: `${label} must be a valid number.` }))
return
}
setModelFieldErrors((current) => {
const next = { ...current }
delete next[key]
return next
})
// Debounced fields fire with their initial value on mount and on
// model/mode switches; committing that echo would persist resolved
// catalog values as user overrides. Only commit actual edits.
// Compare against the pending override when one is in flight so a
// quick revert during a commit round-trip is not mistaken for an
// echo of the (stale) displayed value.
const pendingOverrides =
selectedModelOverridesRef.current.modelId === selectedModelId?.trim()
? selectedModelOverridesRef.current.overrides
: undefined
const effectiveValue =
pendingOverrides && Object.hasOwn(pendingOverrides, key)
? displayedModelNumber(pendingOverrides[key] as number | undefined)
: displayedModelNumber(openAiModelInfo?.[key])
if (parsed.value === effectiveValue) {
return
}
updateModelOverride(key, parsed.value)
},
[updateModelOverride, openAiModelInfo, selectedModelId],
)
// Debounced function to refresh OpenAI models (prevents excessive API calls while typing)
@@ -130,6 +191,7 @@ export const OpenAICompatibleProvider = ({
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current)
}
openAiModelsRequestRef.current += 1
}
}, [])
@@ -196,22 +258,14 @@ export const OpenAICompatibleProvider = ({
void refreshOpenAiModels(config?.baseUrl, latestOpenAiApiKeyRef.current)
}, [config?.baseUrl, refreshOpenAiModels])
const toOpenAiModelInfo = useCallback(
(modelId: string): ModelInfo => ({
...openAiModelInfoSafeDefaults,
name: modelId,
}),
[],
)
const handleOpenAiModelSelection = useCallback(
(modelId: string, modelInfo = toOpenAiModelInfo(modelId)) => {
(modelId: string) => {
if (isOpenAiProvider) {
handleModeFieldChange({ plan: "planModeOpenAiModelId", act: "actModeOpenAiModelId" }, modelId, currentMode)
}
commitOpenAiSelection(modelId, modelInfo)
commitOpenAiSelection(modelId)
},
[commitOpenAiSelection, currentMode, handleModeFieldChange, isOpenAiProvider, toOpenAiModelInfo],
[commitOpenAiSelection, currentMode, handleModeFieldChange, isOpenAiProvider],
)
const { savedApiKeyMask, handleApiKeyChange } = useProviderApiKeyField({
@@ -261,7 +315,10 @@ export const OpenAICompatibleProvider = ({
<ApiKeyField initialValue={savedApiKeyMask} onChange={handleApiKeyChange} providerName="OpenAI Compatible" />
{isRefreshingOpenAiModels && <div role="status">Loading models…</div>}
<label htmlFor="openai-compatible-model-picker">
<span style={{ fontWeight: 500 }}>Model ID</span>
{isRefreshingOpenAiModels && <span> Loading models…</span>}
</label>
{openAiModelsError && <div role="alert">{openAiModelsError}</div>}
{availableOpenAiModels.length > 0 ? (
<div
@@ -271,9 +328,6 @@ export const OpenAICompatibleProvider = ({
gap: 8,
marginBottom: 10,
}}>
<label htmlFor="openai-compatible-model-picker">
<span style={{ fontWeight: 500 }}>Model ID</span>
</label>
<select
aria-label="Model ID"
id="openai-compatible-model-picker"
@@ -316,9 +370,8 @@ export const OpenAICompatibleProvider = ({
initialValue={selectedModelId || ""}
onChange={(value) => handleOpenAiModelSelection(value)}
placeholder={"Enter Model ID..."}
style={{ width: "100%", marginBottom: 10 }}>
<span style={{ fontWeight: 500 }}>Model ID</span>
</DebouncedTextField>
style={{ width: "100%", marginBottom: 10 }}
/>
)}
{/* OpenAI Compatible Custom Headers */}
@@ -475,105 +528,67 @@ export const OpenAICompatibleProvider = ({
<>
<VSCodeCheckbox
checked={!!openAiModelInfo?.supportsImages}
onChange={(e: any) => {
const isChecked = e.target.checked === true
const modelInfo = openAiModelInfo ? { ...openAiModelInfo } : { ...openAiModelInfoSafeDefaults }
modelInfo.supportsImages = isChecked
handleOpenAiModelInfoChange(modelInfo)
}}>
onChange={(e: any) => updateModelOverride("supportsVision", e.target.checked === true)}>
Supports Images
</VSCodeCheckbox>
<VSCodeCheckbox
checked={!!openAiModelInfo?.isR1FormatRequired}
onChange={(e: any) => {
const isChecked = e.target.checked === true
let modelInfo = openAiModelInfo ? { ...openAiModelInfo } : { ...openAiModelInfoSafeDefaults }
modelInfo = { ...modelInfo, isR1FormatRequired: isChecked }
handleOpenAiModelInfoChange(modelInfo)
}}>
checked={selectedModelOverrides.isR1FormatRequired ?? openAiModelInfo.apiFormat === ApiFormat.R1_CHAT}
onChange={(e: any) => updateModelOverride("isR1FormatRequired", e.target.checked === true)}>
Enable R1 messages format
</VSCodeCheckbox>
<div style={{ display: "flex", gap: 10, marginTop: "5px" }}>
<DebouncedTextField
initialValue={
openAiModelInfo?.contextWindow
? openAiModelInfo.contextWindow.toString()
: (openAiModelInfoSafeDefaults.contextWindow?.toString() ?? "")
}
onChange={(value) => {
const modelInfo = openAiModelInfo ? { ...openAiModelInfo } : { ...openAiModelInfoSafeDefaults }
modelInfo.contextWindow = Number(value)
handleOpenAiModelInfoChange(modelInfo)
}}
style={{ flex: 1 }}>
<span style={{ fontWeight: 500 }}>Context Window Size</span>
</DebouncedTextField>
<div style={{ flex: 1 }}>
<DebouncedTextField
initialValue={formatOptionalModelNumber(openAiModelInfo?.contextWindow)}
onChange={(value) => updateNumericModelOverride("contextWindow", "Context Window Size", value)}>
<span style={{ fontWeight: 500 }}>Context Window Size</span>
</DebouncedTextField>
{modelFieldErrors.contextWindow && <div role="alert">{modelFieldErrors.contextWindow}</div>}
</div>
<DebouncedTextField
initialValue={
openAiModelInfo?.maxTokens
? openAiModelInfo.maxTokens.toString()
: (openAiModelInfoSafeDefaults.maxTokens?.toString() ?? "")
}
onChange={(value) => {
const modelInfo = openAiModelInfo ? { ...openAiModelInfo } : { ...openAiModelInfoSafeDefaults }
modelInfo.maxTokens = Number(value)
handleOpenAiModelInfoChange(modelInfo)
}}
style={{ flex: 1 }}>
<span style={{ fontWeight: 500 }}>Max Output Tokens</span>
</DebouncedTextField>
<div style={{ flex: 1 }}>
<DebouncedTextField
initialValue={formatOptionalModelNumber(openAiModelInfo?.maxTokens)}
onChange={(value) => updateNumericModelOverride("maxTokens", "Max Output Tokens", value)}
placeholder="not set">
<span style={{ fontWeight: 500 }}>Max Output Tokens</span>
</DebouncedTextField>
{modelFieldErrors.maxTokens && <div role="alert">{modelFieldErrors.maxTokens}</div>}
</div>
</div>
<div style={{ display: "flex", gap: 10, marginTop: "5px" }}>
<DebouncedTextField
initialValue={
openAiModelInfo?.inputPrice
? openAiModelInfo.inputPrice.toString()
: (openAiModelInfoSafeDefaults.inputPrice?.toString() ?? "")
}
onChange={(value) => {
const modelInfo = openAiModelInfo ? { ...openAiModelInfo } : { ...openAiModelInfoSafeDefaults }
modelInfo.inputPrice = parsePrice(value, openAiModelInfoSafeDefaults.inputPrice ?? 0)
handleOpenAiModelInfoChange(modelInfo)
}}
style={{ flex: 1 }}>
<span style={{ fontWeight: 500 }}>Input Price / 1M tokens</span>
</DebouncedTextField>
<div style={{ flex: 1 }}>
<DebouncedTextField
initialValue={formatOptionalModelNumber(openAiModelInfo?.inputPrice)}
onChange={(value) => updateNumericModelOverride("inputPrice", "Input Price", value)}>
<span style={{ fontWeight: 500 }}>Input Price / 1M tokens</span>
</DebouncedTextField>
{modelFieldErrors.inputPrice && <div role="alert">{modelFieldErrors.inputPrice}</div>}
</div>
<DebouncedTextField
initialValue={
openAiModelInfo?.outputPrice
? openAiModelInfo.outputPrice.toString()
: (openAiModelInfoSafeDefaults.outputPrice?.toString() ?? "")
}
onChange={(value) => {
const modelInfo = openAiModelInfo ? { ...openAiModelInfo } : { ...openAiModelInfoSafeDefaults }
modelInfo.outputPrice = parsePrice(value, openAiModelInfoSafeDefaults.outputPrice ?? 0)
handleOpenAiModelInfoChange(modelInfo)
}}
style={{ flex: 1 }}>
<span style={{ fontWeight: 500 }}>Output Price / 1M tokens</span>
</DebouncedTextField>
<div style={{ flex: 1 }}>
<DebouncedTextField
initialValue={formatOptionalModelNumber(openAiModelInfo?.outputPrice)}
onChange={(value) => updateNumericModelOverride("outputPrice", "Output Price", value)}>
<span style={{ fontWeight: 500 }}>Output Price / 1M tokens</span>
</DebouncedTextField>
{modelFieldErrors.outputPrice && <div role="alert">{modelFieldErrors.outputPrice}</div>}
</div>
</div>
<div style={{ display: "flex", gap: 10, marginTop: "5px" }}>
<DebouncedTextField
initialValue={
openAiModelInfo?.temperature
? openAiModelInfo.temperature.toString()
: (openAiModelInfoSafeDefaults.temperature?.toString() ?? "")
}
onChange={(value) => {
const modelInfo = openAiModelInfo ? { ...openAiModelInfo } : { ...openAiModelInfoSafeDefaults }
modelInfo.temperature = parsePrice(value, openAiModelInfoSafeDefaults.temperature ?? 0)
handleOpenAiModelInfoChange(modelInfo)
}}>
<span style={{ fontWeight: 500 }}>Temperature</span>
</DebouncedTextField>
<div>
<DebouncedTextField
initialValue={formatOptionalModelNumber(openAiModelInfo?.temperature)}
onChange={(value) => updateNumericModelOverride("temperature", "Temperature", value)}
placeholder="not set">
<span style={{ fontWeight: 500 }}>Temperature</span>
</DebouncedTextField>
{modelFieldErrors.temperature && <div role="alert">{modelFieldErrors.temperature}</div>}
</div>
</div>
</>
)}
@@ -610,3 +625,25 @@ export const OpenAICompatibleProvider = ({
</div>
)
}
type NumericModelOverrideKey = "contextWindow" | "maxTokens" | "inputPrice" | "outputPrice" | "temperature"
type ParsedOptionalNumber = { valid: true; value: number | undefined } | { valid: false }
// -1 is the legacy UI sentinel for "not set"; it renders (and compares) as unset.
function displayedModelNumber(value: number | undefined): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value !== -1 ? value : undefined
}
function formatOptionalModelNumber(value: number | undefined): string {
return displayedModelNumber(value)?.toString() ?? ""
}
function parseOptionalFiniteNumber(value: string): ParsedOptionalNumber {
const trimmed = value.trim()
if (!trimmed) {
return { valid: true, value: undefined }
}
const parsed = Number(trimmed)
return Number.isFinite(parsed) ? { valid: true, value: parsed } : { valid: false }
}
@@ -1,4 +1,3 @@
import { openAiModelInfoSafeDefaults } from "@shared/api"
import { fromProtobufModelInfo } from "@shared/proto-conversions/models/typeConversion"
import type { Mode } from "@shared/storage/types"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
@@ -35,7 +34,6 @@ export const OpenAiCodexProvider = ({ showModelOptions, isPopup, currentMode }:
const { config, commitSelection } = useProviderConfig(OPENAI_CODEX_PROVIDER_ID)
const {
models,
defaultModelId,
selectedModelId: legacySelectedModelId,
selectedModelInfo: legacySelectedModelInfo,
hideUsageCost,
@@ -53,13 +51,9 @@ export const OpenAiCodexProvider = ({ showModelOptions, isPopup, currentMode }:
return
}
const fallbackModelId = defaultModelId || Object.keys(models)[0] || modelId
const modelInfo = models[modelId] ?? models[fallbackModelId] ?? selectedModelInfo ?? openAiModelInfoSafeDefaults
void commitSelection(currentMode, {
providerId: OPENAI_CODEX_PROVIDER_ID,
modelId,
modelInfo,
}).catch((err) => console.error("Failed to commit OpenAI Codex model selection:", err))
}
@@ -1,4 +1,3 @@
import { openAiModelInfoSafeDefaults } from "@shared/api"
import { EmptyRequest } from "@shared/proto/cline/common"
import type { Mode } from "@shared/storage/types"
import { parseVsCodeLmModelSelector, stringifyVsCodeLmModelSelector } from "@shared/vsCodeSelectorUtils"
@@ -66,8 +65,7 @@ export const VSCodeLmProvider = ({ currentMode }: VSCodeLmProviderProps) => {
void commitSelection(currentMode, {
providerId: "vscode-lm",
modelId,
modelInfo: {
...openAiModelInfoSafeDefaults,
overrides: {
name: [selector.vendor, selector.family].filter(Boolean).join(" - ") || modelId,
},
}).catch((err) => console.error("Failed to commit VS Code LM model selection:", err))
@@ -1,9 +1,9 @@
import { StringRequest } from "@shared/proto/cline/common"
import { ApiFormat, ProviderConfigResponse } from "@shared/proto/cline/models"
import { ApiFormat, ModelOverrides, ProviderConfigResponse } from "@shared/proto/cline/models"
import { act, renderHook, waitFor } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { ModelsServiceClient } from "@/services/grpc-client"
import { useProviderConfig } from "./useProviderConfig"
import { fromProtobufProviderModelOverrides, toProtobufProviderModelOverrides, useProviderConfig } from "./useProviderConfig"
vi.mock("@/services/grpc-client", () => ({
ModelsServiceClient: {
@@ -57,7 +57,7 @@ describe("useProviderConfig", () => {
expect(result.current.config?.baseUrl).toBe("https://custom.example/v1")
})
it("commitSelection sends the full selection envelope and refreshes config", async () => {
it("commitSelection sends model settings and refreshes config", async () => {
vi.mocked(ModelsServiceClient.readProviderConfig)
.mockResolvedValueOnce(config())
.mockResolvedValueOnce(
@@ -76,6 +76,11 @@ describe("useProviderConfig", () => {
apiFormat: ApiFormat.OPENAI_CHAT,
tiers: [],
},
overrides: ModelOverrides.create({
apiFormat: ApiFormat.OPENAI_RESPONSES,
capabilities: ["tools", "streaming"],
temperature: 0.4,
}),
},
}),
)
@@ -87,7 +92,7 @@ describe("useProviderConfig", () => {
await result.current.commitSelection("act", {
providerId: "deepseek",
modelId: "deepseek-v4-flash",
modelInfo: { name: "DeepSeek V4 Flash", supportsPromptCache: true, apiFormat: ApiFormat.OPENAI_CHAT },
overrides: { name: "DeepSeek V4 Flash", capabilities: ["prompt-cache"] },
})
})
@@ -96,15 +101,64 @@ describe("useProviderConfig", () => {
providerId: "deepseek",
mode: "act",
modelId: "deepseek-v4-flash",
modelInfo: expect.objectContaining({
overrides: expect.objectContaining({
name: "DeepSeek V4 Flash",
supportsPromptCache: true,
apiFormat: ApiFormat.OPENAI_CHAT,
capabilities: ["prompt-cache"],
}),
}),
)
expect(ModelsServiceClient.readProviderConfig).toHaveBeenCalledTimes(2)
expect(result.current.config?.actSelection?.modelId).toBe("deepseek-v4-flash")
expect(fromProtobufProviderModelOverrides(result.current.config?.actSelection?.overrides)).toEqual({
apiFormat: ApiFormat.OPENAI_RESPONSES,
capabilities: ["tools", "streaming"],
temperature: 0.4,
})
})
it("converts every domain override field explicitly and preserves an empty override message", () => {
const overrides = {
name: "Custom",
maxTokens: 8_192,
contextWindow: 128_000,
maxInputTokens: 120_000,
capabilities: ["tools", "streaming"],
supportsVision: false,
supportsAttachments: true,
supportsReasoning: false,
inputPrice: 1,
outputPrice: 2,
cacheReadsPrice: 3,
cacheWritesPrice: 4,
temperature: 0.2,
apiFormat: ApiFormat.OPENAI_RESPONSES,
isR1FormatRequired: false,
}
expect(fromProtobufProviderModelOverrides(toProtobufProviderModelOverrides(overrides))).toEqual(overrides)
expect(toProtobufProviderModelOverrides({})).toEqual(ModelOverrides.create({}))
})
it("sends an explicit empty override message so the host can clear stored overrides", async () => {
vi.mocked(ModelsServiceClient.readProviderConfig).mockResolvedValue(config())
vi.mocked(ModelsServiceClient.commitModelSelection).mockResolvedValue({})
const { result } = renderHook(() => useProviderConfig("deepseek"))
await waitFor(() => expect(result.current.config).toBeDefined())
await act(async () => {
await result.current.commitSelection("act", {
providerId: "deepseek",
modelId: "custom-model",
overrides: {},
})
})
expect(ModelsServiceClient.commitModelSelection).toHaveBeenCalledWith(
expect.objectContaining({
modelId: "custom-model",
overrides: ModelOverrides.create({}),
}),
)
})
it("commitSelection rejects mismatched provider ids without calling RPC", async () => {
@@ -116,7 +170,6 @@ describe("useProviderConfig", () => {
result.current.commitSelection("act", {
providerId: "openrouter",
modelId: "deepseek-v4-flash",
modelInfo: { supportsPromptCache: true },
}),
).rejects.toThrow("selection providerId openrouter does not match hook providerId deepseek")
expect(ModelsServiceClient.commitModelSelection).not.toHaveBeenCalled()
@@ -7,11 +7,13 @@ import {
WriteProviderConfigPatch,
WriteProviderConfigRequest,
} from "@shared/proto/cline/models"
import { toProtobufModelInfo } from "@shared/proto-conversions/models/typeConversion"
import {
type ProviderModelOverrides,
toProtobufModelOverrides as toProtobufProviderModelOverrides,
} from "@shared/proto-conversions/models/modelOverrides"
import { useCallback, useEffect, useState } from "react"
import type { ProviderId } from "@/context/ExtensionStateContext"
import { ModelsServiceClient } from "@/services/grpc-client"
import type { ModelInfo } from "../../../src/shared/api"
export type ProviderConfigWritePatch = Partial<Omit<WriteProviderConfigPatch, "headers" | "aws" | "gcp">> & {
headers?: Record<string, string>
@@ -19,10 +21,23 @@ export type ProviderConfigWritePatch = Partial<Omit<WriteProviderConfigPatch, "h
gcp?: Partial<GcpProviderConfig>
}
// The overrides domain type and its proto conversions are shared with the
// host; see the tri-state and normalization semantics documented there.
export {
fromProtobufModelOverrides as fromProtobufProviderModelOverrides,
toProtobufModelOverrides as toProtobufProviderModelOverrides,
} from "@shared/proto-conversions/models/modelOverrides"
export type { ProviderModelOverrides }
export interface ProviderModelSelection {
providerId: ProviderId
modelId: string
modelInfo: ModelInfo
/**
* Tri-state: `undefined` preserves the model's stored overrides, an
* explicitly empty object clears them, and a non-empty object replaces
* them wholesale.
*/
overrides?: ProviderModelOverrides
}
function toWriteProviderConfigPatch(patch: ProviderConfigWritePatch): WriteProviderConfigPatch {
@@ -74,7 +89,8 @@ export function useProviderConfig(providerId: ProviderId) {
providerId,
mode,
modelId: selection.modelId,
modelInfo: toProtobufModelInfo(selection.modelInfo),
overrides:
selection.overrides !== undefined ? toProtobufProviderModelOverrides(selection.overrides) : undefined,
}),
)
await read()
@@ -0,0 +1,66 @@
import { ApiFormat } from "@shared/proto/cline/models"
import { act, renderHook } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"
import { useProviderModelSelection } from "./useProviderModelSelection"
describe("useProviderModelSelection", () => {
it("does not turn custom fallback model info into persisted overrides", async () => {
const commitSelection = vi.fn(async () => undefined)
const customModelInfo = {
name: "Custom model",
contextWindow: 128_000,
maxTokens: -1,
inputPrice: 0,
outputPrice: 0,
temperature: 0,
}
const { result } = renderHook(() =>
useProviderModelSelection("custom-provider", "act", {
models: {},
commitSelection,
customModelInfo: () => customModelInfo,
}),
)
await act(async () => {
await result.current.commitModelSelection({ modelId: "custom-model", modelInfo: customModelInfo })
})
expect(commitSelection).toHaveBeenCalledWith("act", {
providerId: "custom-provider",
modelId: "custom-model",
})
})
it("forwards only explicitly supplied overrides", async () => {
const commitSelection = vi.fn(async () => undefined)
const { result } = renderHook(() =>
useProviderModelSelection("custom-provider", "act", {
models: {},
commitSelection,
}),
)
await act(async () => {
await result.current.commitModelSelection({
modelId: "custom-model",
modelInfo: { contextWindow: 128_000, maxTokens: -1, temperature: -1 },
overrides: {
apiFormat: ApiFormat.OPENAI_RESPONSES,
capabilities: ["tools", "streaming"],
temperature: 0.2,
},
})
})
expect(commitSelection).toHaveBeenCalledWith("act", {
providerId: "custom-provider",
modelId: "custom-model",
overrides: {
apiFormat: ApiFormat.OPENAI_RESPONSES,
capabilities: ["tools", "streaming"],
temperature: 0.2,
},
})
})
})
@@ -6,6 +6,14 @@ import { useCallback } from "react"
import type { ProviderId } from "@/context/ExtensionStateContext"
import type { ProviderModelSelection } from "./useProviderConfig"
type ProviderModelSelectionInput =
| (Omit<ProviderModelSelection, "providerId"> & { modelInfo?: ModelInfo })
| (ProviderModelSelection & { modelInfo?: ModelInfo })
interface DisplayProviderModelSelection extends ProviderModelSelection {
modelInfo: ModelInfo
}
interface UseProviderModelSelectionOptions {
models: Record<string, ModelInfo>
defaultModelId?: string
@@ -36,17 +44,18 @@ export function useProviderModelSelection(
(selectedModelId && customModelInfo ? customModelInfo(selectedModelId) : undefined) ??
fallbackModelInfo)
const selectedModel: ProviderModelSelection = {
const selectedModel: DisplayProviderModelSelection = {
providerId,
modelId: selectedModelId,
modelInfo: selectedModelInfo,
}
const commitModelSelection = useCallback(
(selection: Omit<ProviderModelSelection, "providerId"> | ProviderModelSelection) => {
(selection: ProviderModelSelectionInput) => {
return commitSelection(currentMode, {
...selection,
providerId,
modelId: selection.modelId,
...(selection.overrides !== undefined ? { overrides: selection.overrides } : {}),
})
},
[commitSelection, currentMode, providerId],
+144 -38
View File
@@ -19,7 +19,7 @@
},
"apps/cli": {
"name": "@cline/cli",
"version": "3.0.40",
"version": "3.0.41",
"bin": {
"cline": "src/index.ts",
},
@@ -491,6 +491,17 @@
"vitest": "^4.0.17",
},
},
"apps/vscode-rollout": {
"name": "@cline/vscode-rollout",
"version": "0.1.0",
"devDependencies": {
"@types/node": "20.x",
"@types/vscode": "1.84.0",
"esbuild": "^0.25.0",
"node-machine-id": "^1.1.12",
"typescript": "^5.4.5",
},
},
"apps/vscode/testing-platform": {
"name": "testing-infra",
"version": "0.1.0",
@@ -621,7 +632,7 @@
},
"sdk/packages/agents": {
"name": "@cline/agents",
"version": "0.0.61",
"version": "0.0.62",
"dependencies": {
"@cline/llms": "workspace:*",
"@cline/shared": "workspace:*",
@@ -630,7 +641,7 @@
},
"sdk/packages/core": {
"name": "@cline/core",
"version": "0.0.61",
"version": "0.0.62",
"dependencies": {
"@cline/agents": "workspace:*",
"@cline/llms": "workspace:*",
@@ -668,7 +679,7 @@
},
"sdk/packages/llms": {
"name": "@cline/llms",
"version": "0.0.61",
"version": "0.0.62",
"dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.89",
"@ai-sdk/anthropic": "^3.0.68",
@@ -686,6 +697,7 @@
"@opentelemetry/sdk-trace-node": "^2.6.1",
"@streamparser/json": "^0.0.21",
"ai": "^6.0.144",
"ai-sdk-ollama": "^3.8.8",
"ai-sdk-provider-claude-code": "^3.4.3",
"ai-sdk-provider-codex-cli": "^1.1.0",
"ai-sdk-provider-opencode-sdk": "^3.0.1",
@@ -702,14 +714,14 @@
},
"sdk/packages/sdk": {
"name": "@cline/sdk",
"version": "0.0.61",
"version": "0.0.62",
"dependencies": {
"@cline/core": "workspace:*",
},
},
"sdk/packages/shared": {
"name": "@cline/shared",
"version": "0.0.61",
"version": "0.0.62",
"dependencies": {
"aws4fetch": "^1.0.20",
"jsonrepair": "^3.13.2",
@@ -784,61 +796,59 @@
"@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="],
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.204", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.204", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.204", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.204", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.204", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.204", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.204", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.204", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.204" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-nicn6OrlmUIuk4cdIkKQWKpHbkz3rL0EkYIsil6m1QejxD5gVDnVdgHuIUuwhcZB0PriBou9VcYLa3vRoyVFiQ=="],
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.205", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.205", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.205", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.205", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.205", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.205", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.205", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.205", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.205" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-ft6iBw9kXudsusiXNpeybIPBJ07Z3tqp1ROSg5cEJqgA+9i+JJj2sRfQth+QD+lyenbbAU8yPieLxIimvfBhtw=="],
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.204", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TYeNVAaeALaTMSLy00n7tUnUCUZEE/rJb7krx3RH5XvWSf/7jGiSDSAvq6aFts1/J7KSefhlctLfA5fPEdmlMQ=="],
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.205", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lrfJ4eVtzfPkCpbSkBOGSMQCBbvmW6nbPzgHE4IwMN3scZlpuFMUFqh2aaJa/X2SAcWD9H2S0t2WWvSRgM7BjA=="],
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.204", "", { "os": "darwin", "cpu": "x64" }, "sha512-azilRc19MvLajTBGR1fqVzK+j1xabPESjrrnoEGU0Ugrgjm+SHxQDI6itXTKwj6TjjLr6sCxEtDTkV5lUzHelg=="],
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.205", "", { "os": "darwin", "cpu": "x64" }, "sha512-G6ETPmL5mNzJ2DFsWxG3jmsmrXgZX1N2ZCJvxaGUUpjTsKZJ4Tup1cWYvcd/m7o5fYZmx9REmgzTwsAIc1fdPQ=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.204", "", { "os": "linux", "cpu": "arm64" }, "sha512-hwlaYrtJDs0Hu+M56QlgAzSSRCOd4JrJfg+nirKKD4dKA/A0J9RzNW7DaaXZu2RWL6+pauOIem72E95LfcLmFw=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.205", "", { "os": "linux", "cpu": "arm64" }, "sha512-CXzySK3PV3EizCRPXnxPqeaAtgrBFDnMFOVpMe36oC3U16yDb1b1tAJGqZi/7uFrVvAiaXvnSFxhUWnDDSaO+A=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.204", "", { "os": "linux", "cpu": "arm64" }, "sha512-btK8EygEeizBFKJxI6xYiJ2EmFqkDcr2l1YPBoOJTg0dPVS6mzH7BUt4Jk6F9c/1UNhzdEWzNq+j2/xUN77W+g=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.205", "", { "os": "linux", "cpu": "arm64" }, "sha512-91fgdG4aTnQ29sKOcUqgH4+tKCW2ut6PWGRSYmXNDbROasJm1rAlPdzC5brdu/e4c0CDSNV6TWyE5JCjaS/jlQ=="],
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.204", "", { "os": "linux", "cpu": "x64" }, "sha512-0sVBc2IbXYc4E8U9feYSuP83G9Z+U+/2VhaLl+qea3RAByR1JzOHjjPa4bXNWHDJWBhMF1t8g7a3QsBi1l21DQ=="],
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.205", "", { "os": "linux", "cpu": "x64" }, "sha512-siS+1iNqBSlGFZZvJY6+mhzZ/6/ec/TbX9GMuwmTF0E6fxGhIIp797jJxR1q8r6FAq7d39mEoRNhC0Ffo60uNQ=="],
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.204", "", { "os": "linux", "cpu": "x64" }, "sha512-ZB2Tp9h1OMjcrEdY0lqSc0wE5Jj1qctwmBk2qaaxbhT0FLAocl4/aUisTOg737aXDnP6R1q7S+7kYTkhox5NLQ=="],
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.205", "", { "os": "linux", "cpu": "x64" }, "sha512-vvsb7GlnA8CTSVvvTkrXjcSeRKqxSM7p/tU3Od9ICAZeWHglptekEyzLEApzLuLbI5ewfFF/F0q3NwOBbo18dg=="],
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.204", "", { "os": "win32", "cpu": "arm64" }, "sha512-Wf2dNtscF6MWYeMaKIPDrIZ8302f9rFjtKDYDAWFbV59Fq+xDbWf4AM6B4hC7ln+NgjwlCf6RY0M8a36hTq0rw=="],
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.205", "", { "os": "win32", "cpu": "arm64" }, "sha512-SpP5zF68weFez/6pKrGzq/UVAJDMDNphWqmkLfOpWTDBL5xy6XlIZw5Bl4EXoVnfi2VLFkwuffNeFe+9SdX7kw=="],
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.204", "", { "os": "win32", "cpu": "x64" }, "sha512-EJXAEiJzeIwpjxqsNW904YbwkpqjQstX6wvWzvryN47/aUQ7aFKPJJgNT/VVRl9gi1r62PIQrpN5c0Kwdv+m5g=="],
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.205", "", { "os": "win32", "cpu": "x64" }, "sha512-kg2kkXyeSoFLruO3Ic2IruLxzBR0xCUtmlJHdWi3SYW7JhAKNJg4fcrdJsWcardmEw23Y2UDGDJbRyxqSVx6wg=="],
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.37.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-tHjX2YbkUBwEgg0JZU3EFSSAQPoK4qQR/NFYa8Vtzd5UAyXzZksCw2In69Rml4R/TyHPBfRYaLK35XiOe33pjw=="],
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="],
"@aws-sdk/client-cognito-identity": ["@aws-sdk/client-cognito-identity@3.1081.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.29", "@aws-sdk/credential-provider-node": "^3.972.64", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-ipDgvZ3Hy8kaFrkwj62yuoghKkZi5WrWTiJnaqGc/ntbuAuijIkRWhzSb8d1MGEE+qb+vFFTrWVFwGSqt76TCA=="],
"@aws-sdk/core": ["@aws-sdk/core@3.975.1", "", { "dependencies": { "@aws-sdk/types": "^3.974.0", "@aws-sdk/xml-builder": "^3.972.34", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.29.2", "@smithy/signature-v4": "^5.6.3", "@smithy/types": "^4.16.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-8qh/6EYb7hl/ZwVfQufhbMEZs1gQIc7GbdrIf4eprQJ7cv042+74nE6l3YDfyWNzb9iPXb8fRyYSHkNIk5eE6Q=="],
"@aws-sdk/core": ["@aws-sdk/core@3.974.29", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@aws-sdk/xml-builder": "^3.972.33", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.29.0", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-yqKcltLbtRh1ubzhRSldIs8jFHNZlyMlgoIccCC0aDVbrB99nXaBdmfr89mK7obWX/NVg4rAMpCpZ6dCDiVBtA=="],
"@aws-sdk/credential-provider-cognito-identity": ["@aws-sdk/credential-provider-cognito-identity@3.972.56", "", { "dependencies": { "@aws-sdk/nested-clients": "^3.997.31", "@aws-sdk/types": "^3.974.0", "@smithy/core": "^3.29.2", "@smithy/types": "^4.16.0", "tslib": "^2.6.2" } }, "sha512-nedaZz6Wz2FGUBMsWhlvBPGIkTMfRuMJ99YDHJI4CaC31JS8WPyTavEAio74cfd5nGhi0A8XASB4fZA5VqTdOQ=="],
"@aws-sdk/credential-provider-cognito-identity": ["@aws-sdk/credential-provider-cognito-identity@3.972.54", "", { "dependencies": { "@aws-sdk/nested-clients": "^3.997.29", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-RSHJ0Fh03mfbKGDyDWasEZB4FQTJDUcSr0wmDzoswuz8FsPWMHUNl/6KP1+XGnY8wgvBOyVdu2TUuIZotm5l6A=="],
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.57", "", { "dependencies": { "@aws-sdk/core": "^3.975.1", "@aws-sdk/types": "^3.974.0", "@smithy/core": "^3.29.2", "@smithy/types": "^4.16.0", "tslib": "^2.6.2" } }, "sha512-1RfJaF7SW1TOnvNGU7kaYjwUf5H3sfm+synGH1bHhRlqcnxCt3szebH3dmKEyY4tuGcbQ6ffzUT89cRitBV8OQ=="],
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.55", "", { "dependencies": { "@aws-sdk/core": "^3.974.29", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-Ah36tYkqyaVnaHkx7VseoTYrHUmwgBps3V+wnrC1idhIIMGlviH0FtrX9EIPdAlVHvXC7FQZLhmHBRz+pLaiWg=="],
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.59", "", { "dependencies": { "@aws-sdk/core": "^3.975.1", "@aws-sdk/types": "^3.974.0", "@smithy/core": "^3.29.2", "@smithy/fetch-http-handler": "^5.6.4", "@smithy/node-http-handler": "^4.9.4", "@smithy/types": "^4.16.0", "tslib": "^2.6.2" } }, "sha512-sRCkpTiFnCdQvuaRVjQ6SVoHu6i7RUpurVo1c4F81HWhPvUJ7Wdp5MNtSdX1O29CNXc8em3O5m52hCjVtAD9SA=="],
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.57", "", { "dependencies": { "@aws-sdk/core": "^3.974.29", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-/vp6i5YEliJqRm5k/BDmYjAyRAMTdkjW6UciVRk9oh/0OfDCWeb/ih7hqte4lFvKXkIbsqe9AdK9LQK6NGardw=="],
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.1", "", { "dependencies": { "@aws-sdk/core": "^3.975.1", "@aws-sdk/credential-provider-env": "^3.972.57", "@aws-sdk/credential-provider-http": "^3.972.59", "@aws-sdk/credential-provider-login": "^3.972.63", "@aws-sdk/credential-provider-process": "^3.972.57", "@aws-sdk/credential-provider-sso": "^3.973.1", "@aws-sdk/credential-provider-web-identity": "^3.972.63", "@aws-sdk/nested-clients": "^3.997.31", "@aws-sdk/types": "^3.974.0", "@smithy/core": "^3.29.2", "@smithy/credential-provider-imds": "^4.4.7", "@smithy/types": "^4.16.0", "tslib": "^2.6.2" } }, "sha512-6d8H6ZAh3ZPKZ6fe1nG2OWeZEZPtt9ravoD1dezPdPtsSkJRoxGAnFSHwKT3E/Te6fHE30zRzjV6TD12rvF6yQ=="],
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.62", "", { "dependencies": { "@aws-sdk/core": "^3.974.29", "@aws-sdk/credential-provider-env": "^3.972.55", "@aws-sdk/credential-provider-http": "^3.972.57", "@aws-sdk/credential-provider-login": "^3.972.61", "@aws-sdk/credential-provider-process": "^3.972.55", "@aws-sdk/credential-provider-sso": "^3.972.61", "@aws-sdk/credential-provider-web-identity": "^3.972.61", "@aws-sdk/nested-clients": "^3.997.29", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/credential-provider-imds": "^4.4.5", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-pQIRiQQs+MUlVnJdWJ7/6KS0WxcLRVfut57OFgwC3cnM1F8mXw3Kh4gAVwj6AtvD6CWx8x6+po4ENRcqe64XrQ=="],
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.63", "", { "dependencies": { "@aws-sdk/core": "^3.975.1", "@aws-sdk/nested-clients": "^3.997.31", "@aws-sdk/types": "^3.974.0", "@smithy/core": "^3.29.2", "@smithy/types": "^4.16.0", "tslib": "^2.6.2" } }, "sha512-GREWRrMj0XnNKMaVa/Mauoaui26qBEHu71WWqXbwZOu/jFQOnPZjTf7u0KtGKC8VGa6VUs9kDWGgocrKNLS9vw=="],
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.61", "", { "dependencies": { "@aws-sdk/core": "^3.974.29", "@aws-sdk/nested-clients": "^3.997.29", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-jtrxWwC7slqxh7DnAWHrwsA3UwCsnlypdYtavGT7EX5p791wxWQys7QzkCZ7JvOMAyylDtPoxyV+ic0zg3rV9g=="],
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.66", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.57", "@aws-sdk/credential-provider-http": "^3.972.59", "@aws-sdk/credential-provider-ini": "^3.973.1", "@aws-sdk/credential-provider-process": "^3.972.57", "@aws-sdk/credential-provider-sso": "^3.973.1", "@aws-sdk/credential-provider-web-identity": "^3.972.63", "@aws-sdk/types": "^3.974.0", "@smithy/core": "^3.29.2", "@smithy/credential-provider-imds": "^4.4.7", "@smithy/types": "^4.16.0", "tslib": "^2.6.2" } }, "sha512-f+qjRXZpz7sgzbc4QB+6nLKfyKFgRRXzWdXbsKPv/VhVRyHsDyq4yBWC/B75BAJpFIcUeI2XR/3gdWJ677zB4A=="],
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.64", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.55", "@aws-sdk/credential-provider-http": "^3.972.57", "@aws-sdk/credential-provider-ini": "^3.972.62", "@aws-sdk/credential-provider-process": "^3.972.55", "@aws-sdk/credential-provider-sso": "^3.972.61", "@aws-sdk/credential-provider-web-identity": "^3.972.61", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/credential-provider-imds": "^4.4.5", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-zyKVYDyMR9VQL/kPi03ygN2vtD9uLMuWRLoJ77KxgZZaS1VlJloI+SzleF9Zg4HWUI+AIu+ZRs8zsJFNqbrxsw=="],
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.57", "", { "dependencies": { "@aws-sdk/core": "^3.975.1", "@aws-sdk/types": "^3.974.0", "@smithy/core": "^3.29.2", "@smithy/types": "^4.16.0", "tslib": "^2.6.2" } }, "sha512-TiVQhuU0pbhIZAUZacbPHMyzrIdiH+lnx+PMY/Pu/b93dJrq3wdZwzUJ0TPpvNxaqbHsxJvQZW3/h/beLiKq7Q=="],
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.55", "", { "dependencies": { "@aws-sdk/core": "^3.974.29", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-x0XjjF0l1WGRtK2vEhTZqCguQuAIZLep9l2+eeEmuxQQjjD3BlGQXY5xADR+l3t576UX+dxRkRtTjEu40l81Vw=="],
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.1", "", { "dependencies": { "@aws-sdk/core": "^3.975.1", "@aws-sdk/nested-clients": "^3.997.31", "@aws-sdk/token-providers": "3.1083.0", "@aws-sdk/types": "^3.974.0", "@smithy/core": "^3.29.2", "@smithy/types": "^4.16.0", "tslib": "^2.6.2" } }, "sha512-3foTZUJ4821Ij60X7K3NJroygiZLnbBmarN+T//O2cjkISan90zElN3NBmgSlDrTQ7Gs6z/yO8V7h60QNcDZHQ=="],
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.61", "", { "dependencies": { "@aws-sdk/core": "^3.974.29", "@aws-sdk/nested-clients": "^3.997.29", "@aws-sdk/token-providers": "3.1081.0", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-d/V0VRsz73i+PHhbult/tx0Y1+de1SNQVsXkcQCmpfeBq7uODy/RTxNsOLpT9ZVHxcRNzbQFuywLKC33fUMIxA=="],
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.63", "", { "dependencies": { "@aws-sdk/core": "^3.975.1", "@aws-sdk/nested-clients": "^3.997.31", "@aws-sdk/types": "^3.974.0", "@smithy/core": "^3.29.2", "@smithy/types": "^4.16.0", "tslib": "^2.6.2" } }, "sha512-8qZLFhM69eKcS37m459ctPR05Qimycm/74OPVioe6wNZabMT54GYhwBju0+J656RkMasNSawWQu+c8CmBe3TUQ=="],
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.61", "", { "dependencies": { "@aws-sdk/core": "^3.974.29", "@aws-sdk/nested-clients": "^3.997.29", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-Bv4n3NOI6hPy+rmr6Bw9R6LnBVRkcp3ncj2E2IKSYJG+0UkysSitWMvbgndNvMxDw7gE1pQ/ErwkNceuKwj7zQ=="],
"@aws-sdk/credential-providers": ["@aws-sdk/credential-providers@3.1083.0", "", { "dependencies": { "@aws-sdk/core": "^3.975.1", "@aws-sdk/credential-provider-cognito-identity": "^3.972.56", "@aws-sdk/credential-provider-env": "^3.972.57", "@aws-sdk/credential-provider-http": "^3.972.59", "@aws-sdk/credential-provider-ini": "^3.973.1", "@aws-sdk/credential-provider-login": "^3.972.63", "@aws-sdk/credential-provider-node": "^3.972.66", "@aws-sdk/credential-provider-process": "^3.972.57", "@aws-sdk/credential-provider-sso": "^3.973.1", "@aws-sdk/credential-provider-web-identity": "^3.972.63", "@aws-sdk/nested-clients": "^3.997.31", "@aws-sdk/types": "^3.974.0", "@smithy/core": "^3.29.2", "@smithy/credential-provider-imds": "^4.4.7", "@smithy/types": "^4.16.0", "tslib": "^2.6.2" } }, "sha512-i2q3Jgt365lZp7BSDqDSf283WvISrXob1zsql093LK3G2svYRHRvcNv995SsKtAzRENwHem2TC2vWeghrgBkxg=="],
"@aws-sdk/credential-providers": ["@aws-sdk/credential-providers@3.1081.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "3.1081.0", "@aws-sdk/core": "^3.974.29", "@aws-sdk/credential-provider-cognito-identity": "^3.972.54", "@aws-sdk/credential-provider-env": "^3.972.55", "@aws-sdk/credential-provider-http": "^3.972.57", "@aws-sdk/credential-provider-ini": "^3.972.62", "@aws-sdk/credential-provider-login": "^3.972.61", "@aws-sdk/credential-provider-node": "^3.972.64", "@aws-sdk/credential-provider-process": "^3.972.55", "@aws-sdk/credential-provider-sso": "^3.972.61", "@aws-sdk/credential-provider-web-identity": "^3.972.61", "@aws-sdk/nested-clients": "^3.997.29", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/credential-provider-imds": "^4.4.5", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-UHWvxd1F5nfBXBRXtQaXWoNT8CYKXZovLQOyz6XZlgFTGc2mWzzPGspOfnh49jwvB2qzsdH046i9jnnmND+64w=="],
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.31", "", { "dependencies": { "@aws-sdk/core": "^3.975.1", "@aws-sdk/signature-v4-multi-region": "^3.996.39", "@aws-sdk/types": "^3.974.0", "@smithy/core": "^3.29.2", "@smithy/fetch-http-handler": "^5.6.4", "@smithy/node-http-handler": "^4.9.4", "@smithy/types": "^4.16.0", "tslib": "^2.6.2" } }, "sha512-BDHTpwcsZHEBNEJzOg/B1BkFYJxAXY50dau/NyVWs3d51F0WgIUGSWZot/Os+N3KpDhXeaXnz37mWffAvduREw=="],
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.29", "", { "dependencies": { "@aws-sdk/core": "^3.974.29", "@aws-sdk/signature-v4-multi-region": "^3.996.38", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-ot6v8J5W8P0w6ryyuIkXP1bHZHTlvwtn83mVCYaBE0GJ6tJX4vPSBx7M98w9O4wmmDruFsDBUMjhEHA+OosUFQ=="],
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.39", "", { "dependencies": { "@aws-sdk/types": "^3.974.0", "@smithy/signature-v4": "^5.6.3", "@smithy/types": "^4.16.0", "tslib": "^2.6.2" } }, "sha512-8+srXqYIF8KYMLC4FxMLEM5Ek7kUNibJu1R4m8/fUhhNYIZZz26oGtKkCr8I/HiG2fFQxBvaGgQZT4/mqRCSnA=="],
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.38", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g=="],
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1083.0", "", { "dependencies": { "@aws-sdk/core": "^3.975.1", "@aws-sdk/nested-clients": "^3.997.31", "@aws-sdk/types": "^3.974.0", "@smithy/core": "^3.29.2", "@smithy/types": "^4.16.0", "tslib": "^2.6.2" } }, "sha512-s0woKnxuHrExLc5L2ArIH5BMkbonHPtt+5hSBM8oknp9M6QTuUmmAmJ2E0EdzCGONrO+8+ADPqvv6UX0nNcc7A=="],
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1081.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.29", "@aws-sdk/nested-clients": "^3.997.29", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-kduAeI6cL+zqwj3gjPh9LhuX7kBZ83msYxutavaR+UPm5K8J7iThJBvNRAsFNyWTji92CSU8dogUgvi9T0BehA=="],
"@aws-sdk/types": ["@aws-sdk/types@3.974.0", "", { "dependencies": { "@smithy/types": "^4.16.0", "tslib": "^2.6.2" } }, "sha512-QIBrw90CDm4O0UaIIzkU6DrFdeJzEb2Va5EPEVpyldj6sHJxB6cshhStJuhZxk3wR3PmjJlYsjPmY1kNb+KGBg=="],
"@aws-sdk/types": ["@aws-sdk/types@3.973.15", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ=="],
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.33", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A=="],
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.34", "", { "dependencies": { "@smithy/types": "^4.16.0", "tslib": "^2.6.2" } }, "sha512-wHhWL1y7sN3enBA8POrPpQM5jCcmu2ozyhbRei4c8OjVcEaEs6yLucLa/pla457ggS/ysuy7bosagz3HaJkZXA=="],
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="],
@@ -1024,6 +1034,8 @@
"@cline/vscode": ["@cline/vscode@workspace:apps/examples/vscode"],
"@cline/vscode-rollout": ["@cline/vscode-rollout@workspace:apps/vscode-rollout"],
"@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="],
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
@@ -1760,9 +1772,9 @@
"@playwright/test": ["@playwright/test@1.61.1", "", { "dependencies": { "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" } }, "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig=="],
"@posthog/core": ["@posthog/core@1.39.6", "", { "dependencies": { "@posthog/types": "^1.392.0" } }, "sha512-o6ajIwN5zXoNP0D4H/QPmOyibNTUkSyOR6ya7AG5U2ywXx4awo72L2KnCoiZPQM5x/bXv6jPBdimH8M18Ax0aw=="],
"@posthog/core": ["@posthog/core@1.40.0", "", { "dependencies": { "@posthog/types": "^1.393.0" } }, "sha512-oGDbIwlTquNwdHbEL5ZLEkuW4UFkkEanfx3QAxDgyVbISv+OAA6YGQwrvo0JD3MUJEbJZvyh8XsX+WYDGw9XHw=="],
"@posthog/types": ["@posthog/types@1.392.1", "", {}, "sha512-Qg6Gl7/1vlr8+gPtBi5gwnLgAgiyFoKOVmTvTtDcvya9cpTwZfna7rQmkGQ4B63CunUYNNbOlqcwiUwUDyTK6w=="],
"@posthog/types": ["@posthog/types@1.393.0", "", {}, "sha512-vzWeEJZ7ERQhFRoQYaP5jzN1JvIu46UJyHXsuv+dTGW2r3sMgREOhNxXLZjmFHwZ8/FOHQoyqqQmXTCXZSfMSg=="],
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
@@ -2744,6 +2756,8 @@
"ai": ["ai@6.0.221", "", { "dependencies": { "@ai-sdk/gateway": "3.0.145", "@ai-sdk/provider": "3.0.13", "@ai-sdk/provider-utils": "4.0.37", "@opentelemetry/api": "^1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cB7qJbNTMuD5spdJEo+guejX0rkjhSQpc4PHITNB+iBFBnGYHLUZOM+uSeIkY4mS4sVVKBKm3kSQQoI5cUwU3g=="],
"ai-sdk-ollama": ["ai-sdk-ollama@3.8.8", "", { "dependencies": { "@ai-sdk/provider": "^3.0.10", "@ai-sdk/provider-utils": "^4.0.30", "jsonrepair": "^3.14.0", "ollama": "^0.6.3" }, "peerDependencies": { "ai": "^6.0.197" } }, "sha512-peWelPf6sVsRULQyYhfyu1dMZhwewRszsbQVfbuhNLflh+ncRXn6pe1BRE3NAcSsWd7JMRZAV5RzcuR3R9ZfaQ=="],
"ai-sdk-provider-claude-code": ["ai-sdk-provider-claude-code@3.5.1", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.1", "@anthropic-ai/claude-agent-sdk": "^0.3.170" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-DxbOp3qIQTAhdvtynhW3Eq+NqAuU8UKVRVzBSdmVnCVMke8xC372Hy/j7FDalC5PkOOdiv7jI9yUzIk6vN1l6g=="],
"ai-sdk-provider-codex-cli": ["ai-sdk-provider-codex-cli@1.2.2", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.1", "jsonc-parser": "^3.3.1" }, "optionalDependencies": { "@openai/codex": "^0.130.0" }, "peerDependencies": { "zod": "^3.0.0 || ^4.0.0" } }, "sha512-hlIWo9KP7/hJaEjXZbxgjVO/FvMn3I+5RSht0PBp3GOBKVFkKeKlrIDvaA8Wi/nAYiAePESPem+fi1NVNfNQJA=="],
@@ -4208,6 +4222,8 @@
"obug": ["obug@2.1.3", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="],
"ollama": ["ollama@0.6.3", "", { "dependencies": { "whatwg-fetch": "^3.6.20" } }, "sha512-KEWEhIqE5wtfzEIZbDCLH51VFZ6Z3ZSa6sIOg/E/tBV8S51flyqBOXi+bRxlOYKDf8i327zG9eSTb8IJxvm3Zg=="],
"omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="],
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
@@ -4360,13 +4376,13 @@
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"posthog-js": ["posthog-js@1.398.6", "", { "dependencies": { "@posthog/core": "^1.39.6", "@posthog/types": "^1.392.1", "core-js": "^3.49.0", "dompurify": "^3.3.2", "fflate": "^0.4.8", "preact": "^10.29.3", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^5.3.0" } }, "sha512-T86lyZ4Eqn0d0a1CJ9dTc+c1eVNw5qQb1JeomvTueYJ23JXYLEva4m9K/epFRoMtD3iqZ7tDD1jzRoaRH7wycA=="],
"posthog-js": ["posthog-js@1.399.0", "", { "dependencies": { "@posthog/core": "^1.40.0", "@posthog/types": "^1.393.0", "core-js": "^3.49.0", "dompurify": "^3.3.2", "fflate": "^0.4.8", "preact": "^10.29.3", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^5.3.0" } }, "sha512-8l+uZJZM3+OAc0D0+iLBMDRVfWF9s26Rt0jv8EC3kMJcA/9oyOets0zDgqIZk2TTfUs3H96ycLE6Lwj1wWG16Q=="],
"posthog-node": ["posthog-node@5.40.0", "", { "dependencies": { "@posthog/core": "^1.39.6" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-DrLfHuauO0W6qruF80iqr5JdmLysef74XzOB4eh36oRLRhxCySLraTqsi2Pj161LZnp9/JNdRDxwT8ei8VK2YA=="],
"powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
"preact": ["preact@10.29.6", "", {}, "sha512-/UzLXnc1jrAS2uHi89XsSECqXVHYzV1VUL1L3esxrmPRmDvKFWtmbabE2a4QQ5a3bLgBivubRCEOt4GGmncPBQ=="],
"preact": ["preact@10.29.7", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q=="],
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
@@ -5064,6 +5080,8 @@
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
"whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="],
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
"whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="],
@@ -5176,6 +5194,12 @@
"@cline/code/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@cline/vscode-rollout/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="],
"@cline/vscode-rollout/@types/vscode": ["@types/vscode@1.84.0", "", {}, "sha512-lCGOSrhT3cL+foUEqc8G1PVZxoDbiMmxgnUZZTEnHF4mC47eKAUtBGAuMLY6o6Ua8PAuNCoKXbqPmJd1JYnQfg=="],
"@cline/vscode-rollout/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
"@discordjs/builders/discord-api-types": ["discord-api-types@0.38.49", "", {}, "sha512-XnqcWmnFZFAE8ZM8SHAw9DIV8D3Or00rMQ8iQLotrEA2PmXhl+ykaf6L6q4l474hrSUH1JaYcv+iOMRWp2p6Tg=="],
@@ -5802,7 +5826,7 @@
"claude-dev/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"cli-truncate/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="],
"cli-truncate/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="],
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@@ -6216,6 +6240,8 @@
"unzipper/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"vaul/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-dismissable-layer": "1.1.15", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.12", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+HhbN2+YtkRgVirjZ2afMeutQRuGOrdkWR5+EFC58SJojGmtyNQwYzgi6tHBpOxvFHefMtPeHdgtjz0BOGxFQg=="],
"verror/core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
"vite-node/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
@@ -6282,6 +6308,60 @@
"@cline/cline-hub-webview/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"@cline/vscode-rollout/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"@cline/vscode-rollout/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@cline/vscode-rollout/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@cline/vscode-rollout/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@cline/vscode-rollout/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@cline/vscode-rollout/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@cline/vscode-rollout/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@cline/vscode-rollout/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@cline/vscode-rollout/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@cline/vscode-rollout/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@cline/vscode-rollout/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@cline/vscode-rollout/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@cline/vscode-rollout/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@cline/vscode-rollout/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@cline/vscode-rollout/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@cline/vscode-rollout/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@cline/vscode-rollout/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@cline/vscode-rollout/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@cline/vscode-rollout/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@cline/vscode-rollout/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@cline/vscode-rollout/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@cline/vscode-rollout/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@cline/vscode-rollout/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@cline/vscode-rollout/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@cline/vscode-rollout/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@cline/vscode-rollout/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@cline/vscode-rollout/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@dotenvx/dotenvx/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
"@dotenvx/dotenvx/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
@@ -6994,6 +7074,28 @@
"unzipper/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"vaul/@radix-ui/react-dialog/@radix-ui/primitive": ["@radix-ui/primitive@1.1.5", "", {}, "sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg=="],
"vaul/@radix-ui/react-dialog/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA=="],
"vaul/@radix-ui/react-dialog/@radix-ui/react-context": ["@radix-ui/react-context@1.2.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg=="],
"vaul/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-effect-event": "0.0.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-b0XaRlzn2QKuo10XyNgi2DAJDf5XC9d1nD3FJcuvCjbR7+4Ad28zmZsLsqx+hvDEzMnRuZaZxZm9gYObV6RmRA=="],
"vaul/@radix-ui/react-dialog/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q=="],
"vaul/@radix-ui/react-dialog/@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.12", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jjk/lqTeNL0azUx5ZYzVrl4NgaDIrdzTNE4mABV9yBFI7FQqN7pIgzV1bTleUezP2QiTGA1BFTqY8MegDgWX9A=="],
"vaul/@radix-ui/react-dialog/@radix-ui/react-id": ["@radix-ui/react-id@1.1.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA=="],
"vaul/@radix-ui/react-dialog/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.13", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA=="],
"vaul/@radix-ui/react-dialog/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.7", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA=="],
"vaul/@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.7", "", { "dependencies": { "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ=="],
"vaul/@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="],
"webview-ui/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"webview-ui/@vitejs/plugin-react-swc/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
@@ -7240,6 +7342,10 @@
"test-exclude/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"vaul/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw=="],
"vaul/@radix-ui/react-dialog/@radix-ui/react-focus-scope/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw=="],
"webview-ui/vitest/@vitest/utils/loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
"webview-ui/vitest/chai/check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
+5
View File
@@ -1,5 +1,10 @@
# Cline SDK Changelog
## 0.0.62
- Fixed Ollama native API routing so context window and timeout settings work again
- Telemetry is no longer attached to hub tool contexts
## 0.0.61
- Context compaction now reports progress status while it runs
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/agents",
"version": "0.0.61",
"version": "0.0.62",
"repository": {
"type": "git",
"url": "https://github.com/cline/cline",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/core",
"description": "Cline Core SDK for Node Runtime",
"version": "0.0.61",
"version": "0.0.62",
"repository": {
"type": "git",
"url": "https://github.com/cline/cline",
@@ -30,6 +30,7 @@ export type DelegatedAgentConnectionConfig = Pick<
| "reasoningEffort"
| "thinkingBudgetTokens"
| "maxTokensPerTurn"
| "temperature"
>;
export interface DelegatedAgentRuntimeConfig
@@ -93,6 +94,7 @@ export function createDelegatedAgentConfigProvider(
reasoningEffort: runtimeConfig.reasoningEffort,
thinkingBudgetTokens: runtimeConfig.thinkingBudgetTokens,
maxTokensPerTurn: runtimeConfig.maxTokensPerTurn,
temperature: runtimeConfig.temperature,
}),
updateConnectionDefaults: (overrides) => {
runtimeConfig = {
@@ -340,6 +340,7 @@ describe("createSpawnAgentTool", () => {
providerId: "cline",
modelId: "stale-model",
apiKey: "oauth-access-old",
temperature: 0.3,
});
const updateConnectionDefaults = vi.spyOn(
configProvider,
@@ -372,6 +373,7 @@ describe("createSpawnAgentTool", () => {
expect.objectContaining({
apiKey: "oauth-access-new",
modelId: "updated-model",
temperature: 0.3,
}),
);
});
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { CLINE_INTERNAL_TELEMETRY_METADATA_KEY } from "../../services/telemetry/tool-context";
import { handleCapabilityProgress } from "./handlers/capability-handlers";
import type { HubTransportContext } from "./handlers/context";
import {
@@ -110,6 +111,58 @@ describe("handleCapabilityProgress", () => {
});
describe("hub client runtime capabilities", () => {
it("omits process-local telemetry from proxied tool contexts", async () => {
const telemetry: Record<string, unknown> = {};
telemetry.self = telemetry;
const request: ClientContributionRequest = vi.fn(
async (_sessionId, _capabilityName, payload) => {
expect(() => JSON.stringify(payload)).not.toThrow();
return { result: "ok" };
},
);
const runtime = createHubClientContributionRuntime({
sessionId: "session-1",
targetClientId: "client-1",
contributions: [
{
kind: "toolExecutor",
executor: "askQuestion",
capabilityName: "tool_executor.askQuestion",
},
{
kind: "tool",
name: "switch_to_act_mode",
description: "Switch to act mode.",
inputSchema: { type: "object" },
capabilityName: "custom_tool.switch_to_act_mode",
},
],
requestCapability: request,
});
const context = {
agentId: "agent-1",
conversationId: "conv-1",
iteration: 2,
metadata: {
modelSupportsImages: true,
[CLINE_INTERNAL_TELEMETRY_METADATA_KEY]: telemetry,
},
};
await runtime.toolExecutors?.askQuestion?.("Continue?", ["Yes"], context);
await runtime.localRuntime.extraTools?.[0]?.execute({}, context);
expect(request).toHaveBeenCalledTimes(2);
for (const [, , payload] of vi.mocked(request).mock.calls) {
expect(payload.context).toEqual({
agentId: "agent-1",
conversationId: "conv-1",
iteration: 2,
metadata: { modelSupportsImages: true },
});
}
});
it("proxies lifecycle hooks through capability requests", async () => {
const request = vi.fn(async () => ({
control: { context: "extra context" },
@@ -51,6 +51,7 @@ import type {
RuntimeSessionConfig,
} from "../../runtime/host/runtime-host";
import { formatRulesForSystemPrompt } from "../../runtime/safety/rules";
import { CLINE_INTERNAL_TELEMETRY_METADATA_KEY } from "../../services/telemetry/tool-context";
import type { CoreSessionConfig } from "../../types/config";
type RequestCapability = (
@@ -218,11 +219,16 @@ export function parseHubClientContributions(
function serializeToolContext(
context: AgentToolContext,
): Record<string, unknown> {
const metadata = context.metadata ? { ...context.metadata } : undefined;
if (metadata) {
delete metadata[CLINE_INTERNAL_TELEMETRY_METADATA_KEY];
}
return {
agentId: context.agentId,
conversationId: context.conversationId,
iteration: context.iteration,
metadata: context.metadata,
metadata:
metadata && Object.keys(metadata).length > 0 ? metadata : undefined,
};
}
+9
View File
@@ -576,6 +576,15 @@ export type {
PluginUninstallResult,
} from "./services/plugin-uninstall";
export { uninstallPlugin } from "./services/plugin-uninstall";
export {
ensureCustomProvidersLoadedSync,
readModelsFileSync,
resolveModelsRegistryPath,
type StoredModelEntry,
type StoredProviderEntry,
syncStoredProviderRegistration,
writeModelsFileSync,
} from "./services/providers/local-provider-registry";
export {
addLocalProvider,
type DeleteLocalProviderRequest,
@@ -57,6 +57,7 @@ describe("buildModelOptions", () => {
reasoningEffort: "high",
thinkingBudgetTokens: 1024,
maxTokensPerTurn: 4096,
temperature: 0.2,
apiTimeoutMs: 60_000,
});
expect(buildModelOptions(config)).toEqual({
@@ -64,6 +65,7 @@ describe("buildModelOptions", () => {
reasoningEffort: "high",
thinkingBudgetTokens: 1024,
maxTokensPerTurn: 4096,
temperature: 0.2,
apiTimeoutMs: 60_000,
});
});
@@ -144,6 +144,9 @@ export function buildModelOptions(
if (config.maxTokensPerTurn !== undefined) {
options.maxTokensPerTurn = config.maxTokensPerTurn;
}
if (config.temperature !== undefined) {
options.temperature = config.temperature;
}
if (config.apiTimeoutMs !== undefined) {
options.apiTimeoutMs = config.apiTimeoutMs;
}
@@ -565,6 +565,7 @@ describe("LocalRuntimeHost", () => {
thinking: true,
reasoningEffort: "high",
thinkingBudgetTokens: 1024,
temperature: 0.3,
}),
prompt: "hello",
interactive: true,
@@ -576,6 +577,7 @@ describe("LocalRuntimeHost", () => {
thinking: true,
reasoningEffort: "high",
thinkingBudgetTokens: 1024,
temperature: 0.3,
}),
);
@@ -571,6 +571,7 @@ export class LocalRuntimeHost implements RuntimeHost {
configWithProvider.reasoningEffort ?? providerConfig.reasoningEffort,
thinkingBudgetTokens: configWithProvider.thinkingBudgetTokens,
maxTokensPerTurn: configWithProvider.maxTokensPerTurn,
temperature: configWithProvider.temperature,
systemPrompt: configWithProvider.systemPrompt,
maxIterations: configWithProvider.maxIterations,
execution: configWithProvider.execution,
@@ -488,6 +488,7 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
reasoningEffort: config.reasoningEffort,
thinkingBudgetTokens: config.thinkingBudgetTokens,
maxTokensPerTurn: config.maxTokensPerTurn,
temperature: config.temperature,
maxIterations: config.maxIterations,
hooks,
extensions: runtimeExtensions,
@@ -200,7 +200,7 @@ describe("createAgentModelFromConfig", () => {
});
});
it("uses explicit per-turn max tokens for gateway request limits", async () => {
it("uses explicit per-turn max tokens and temperature for gateway request limits", async () => {
const { createAgentModelFromConfig } = await import("./handler-factory");
createAgentModelFromConfig(
@@ -211,6 +211,7 @@ describe("createAgentModelFromConfig", () => {
systemPrompt: "",
tools: [],
maxTokensPerTurn: 4_096,
temperature: 0,
providerConfig: {
providerId: "openai-compatible",
modelId: "custom-model",
@@ -221,7 +222,7 @@ describe("createAgentModelFromConfig", () => {
expect(gatewayMock.createAgentModel).toHaveBeenLastCalledWith(
{ providerId: "openai-compatible", modelId: "custom-model" },
{ maxTokens: 4_096 },
{ maxTokens: 4_096, temperature: 0 },
);
});
@@ -301,6 +302,152 @@ describe("createAgentModelFromConfig", () => {
);
});
it("forwards a caller-supplied timeout to the gateway provider config", async () => {
const { createAgentModelFromConfig } = await import("./handler-factory");
createAgentModelFromConfig(
{
providerId: "ollama",
modelId: "minimax-m3:cloud",
systemPrompt: "",
tools: [],
providerConfig: {
providerId: "ollama",
modelId: "minimax-m3:cloud",
timeoutMs: 180000,
},
},
undefined,
);
expect(gatewayMock.createGateway).toHaveBeenLastCalledWith(
expect.objectContaining({
providerConfigs: [
expect.objectContaining({
providerId: "ollama",
timeoutMs: 180000,
}),
],
}),
);
});
it("projects providers.json contextWindow (maxInputTokens) onto the selected gateway model", async () => {
const { createAgentModelFromConfig } = await import("./handler-factory");
createAgentModelFromConfig(
{
providerId: "ollama",
modelId: "llama3.1",
systemPrompt: "",
tools: [],
providerConfig: {
providerId: "ollama",
modelId: "llama3.1",
// Where ProviderSettings.contextWindow lands via toProviderConfig.
maxInputTokens: 8192,
knownModels: {
"llama3.1": {
id: "llama3.1",
name: "llama3.1",
contextWindow: 131072,
},
},
},
},
undefined,
);
expect(gatewayMock.createGateway).toHaveBeenLastCalledWith(
expect.objectContaining({
providerConfigs: [
expect.objectContaining({
providerId: "ollama",
models: [
expect.objectContaining({
id: "llama3.1",
contextWindow: 8192,
maxInputTokens: 8192,
}),
],
}),
],
}),
);
});
it("surfaces a caller-supplied modelInfo for the selected model as a gateway model definition", async () => {
const { createAgentModelFromConfig } = await import("./handler-factory");
createAgentModelFromConfig(
{
providerId: "ollama",
modelId: "minimax-m3:cloud",
systemPrompt: "",
tools: [],
providerConfig: {
providerId: "ollama",
modelId: "minimax-m3:cloud",
modelInfo: {
id: "minimax-m3:cloud",
name: "minimax-m3:cloud",
contextWindow: 500000,
},
},
},
undefined,
);
expect(gatewayMock.createGateway).toHaveBeenLastCalledWith(
expect.objectContaining({
providerConfigs: [
expect.objectContaining({
providerId: "ollama",
models: [
expect.objectContaining({
id: "minimax-m3:cloud",
contextWindow: 500000,
}),
],
}),
],
}),
);
});
it("ignores a caller-supplied modelInfo for a different model id", async () => {
const { createAgentModelFromConfig } = await import("./handler-factory");
createAgentModelFromConfig(
{
providerId: "ollama",
modelId: "llama3.1",
systemPrompt: "",
tools: [],
providerConfig: {
providerId: "ollama",
modelId: "llama3.1",
modelInfo: {
id: "some-other-model",
contextWindow: 500000,
},
},
},
undefined,
);
expect(gatewayMock.createGateway).toHaveBeenLastCalledWith(
expect.objectContaining({
providerConfigs: [
expect.objectContaining({
providerId: "ollama",
models: undefined,
}),
],
}),
);
});
it("forwards SAP AI Core settings as gateway provider options", async () => {
const { createAgentModelFromConfig } = await import("./handler-factory");
@@ -81,19 +81,51 @@ function buildGatewayProviderOptions(
return compactOptions(options);
}
function readPositiveInteger(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0
? Math.floor(value)
: undefined;
}
export function resolveKnownModelsFromConfig(
config: AgentConfig,
): Record<string, ModelInfo> | undefined {
const pc = config.providerConfig as ProviderConfig | undefined;
if (pc?.knownModels) {
return pc.knownModels;
const knownModels = pc?.knownModels
? pc.knownModels
: (config.knownModels ??
MODEL_COLLECTIONS_BY_PROVIDER_ID[config.providerId]?.models ??
undefined);
// Caller-configured limits are authoritative for the selected model —
// surface them to the gateway so the resolved model definition carries
// the right limits (e.g. Ollama's num_ctx derives from the resolved
// model's context window):
// - `maxInputTokens` is where `ProviderSettings.contextWindow` lands via
// `toProviderConfig` (the providers.json path used by CLI/Core hosts).
// - `modelInfo` is an explicit per-model override (the VS Code path);
// it wins over the generic limit.
const configuredContextWindow = readPositiveInteger(pc?.maxInputTokens);
const modelInfo =
pc?.modelInfo && pc.modelInfo.id === config.modelId
? pc.modelInfo
: undefined;
if (configuredContextWindow === undefined && !modelInfo) {
return knownModels;
}
if (config.knownModels) {
return config.knownModels;
}
return (
MODEL_COLLECTIONS_BY_PROVIDER_ID[config.providerId]?.models ?? undefined
);
return {
...(knownModels ?? {}),
[config.modelId]: {
...knownModels?.[config.modelId],
...(configuredContextWindow !== undefined
? {
contextWindow: configuredContextWindow,
maxInputTokens: configuredContextWindow,
}
: {}),
...modelInfo,
id: config.modelId,
},
};
}
function toGatewayCapabilities(
@@ -164,6 +196,7 @@ export function createAgentModelFromConfig(
headers: config.headers ?? baseProviderConfig?.headers,
knownModels: resolveKnownModelsFromConfig(config),
maxOutputTokens: config.maxTokensPerTurn,
temperature: config.temperature,
reasoningEffort: config.reasoningEffort,
thinkingBudgetTokens: config.thinkingBudgetTokens,
thinking: config.thinking,
@@ -199,6 +232,7 @@ export function createAgentModelFromConfig(
apiKey: normalizedProviderConfig.apiKey,
baseUrl: normalizedProviderConfig.baseUrl,
headers: normalizedProviderConfig.headers,
timeoutMs: normalizedProviderConfig.timeoutMs,
fetch: normalizedProviderConfig.fetch,
options: buildGatewayProviderOptions(normalizedProviderConfig),
models: normalizedProviderConfig.knownModels
@@ -216,6 +250,9 @@ export function createAgentModelFromConfig(
providerId: normalizedProviderConfig.providerId,
modelId: normalizedProviderConfig.modelId,
},
{ maxTokens: normalizedProviderConfig.maxOutputTokens },
{
maxTokens: normalizedProviderConfig.maxOutputTokens,
temperature: normalizedProviderConfig.temperature,
},
);
}
@@ -164,12 +164,14 @@ async function mergeKnownModels(
// For providers with a registered public model source (Ollama, LM Studio),
// the live response is the authoritative list of what the user has
// actually installed. Skip the bundled catalog so the picker doesn't
// show models that aren't downloaded.
// show models that aren't downloaded — even when the live fetch fails or
// returns nothing. Falling back to the bundled (cloud) catalog here would
// auto-select a model the user never installed (e.g. Ollama silently
// defaulting to a cloud nemotron model when the local server is down).
const hasPublicModelSource = Boolean(
Llms.MODEL_COLLECTIONS_BY_PROVIDER_ID[providerId]?.provider.modelsSourceUrl,
);
const publicHasResults = Object.keys(publicModels).length > 0;
if (hasPublicModelSource && publicHasResults) {
if (hasPublicModelSource) {
return Llms.sortModelsByReleaseDate({
...publicModels,
...userKnownModels,
@@ -1,8 +1,16 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import {
existsSync,
mkdirSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import * as LlmsModels from "@cline/llms";
import {
ApiFormatSchema,
type ModelCapability,
ModelCapabilitySchema,
type ModelInfo,
@@ -15,23 +23,44 @@ import {
ProviderProtocolSchema,
} from "@cline/shared";
import { z } from "zod";
import { sdkDebug } from "../../logging/early-logger";
import type {
ProviderSettings,
StoredProviderSettings,
} from "../../types/provider-settings";
import type { ProviderSettingsManager } from "../storage/provider-settings-manager";
const OptionalPositiveFiniteNumberSchema = z
.number()
.finite()
.positive()
.optional()
.catch(undefined);
const OptionalNonNegativeFiniteNumberSchema = z
.number()
.finite()
.nonnegative()
.optional()
.catch(undefined);
export const StoredModelEntrySchema = z
.object({
id: z.string().optional(),
name: z.string().optional(),
maxTokens: z.number().optional(),
contextWindow: z.number().optional(),
maxInputTokens: z.number().optional(),
maxTokens: OptionalPositiveFiniteNumberSchema,
contextWindow: OptionalPositiveFiniteNumberSchema,
maxInputTokens: OptionalPositiveFiniteNumberSchema,
capabilities: z.array(ModelCapabilitySchema).optional(),
supportsVision: z.boolean().optional(),
supportsAttachments: z.boolean().optional(),
supportsReasoning: z.boolean().optional(),
inputPrice: OptionalNonNegativeFiniteNumberSchema,
outputPrice: OptionalNonNegativeFiniteNumberSchema,
cacheReadsPrice: OptionalNonNegativeFiniteNumberSchema,
cacheWritesPrice: OptionalNonNegativeFiniteNumberSchema,
temperature: OptionalNonNegativeFiniteNumberSchema,
apiFormat: ApiFormatSchema.optional(),
isR1FormatRequired: z.boolean().optional(),
})
.passthrough();
@@ -93,6 +122,9 @@ export function emptyModelsFile(): StoredModelsFile {
export function parseModelsFile(input: unknown): StoredModelsFile {
const result = StoredModelsFileEnvelopeSchema.safeParse(input);
if (!result.success) {
sdkDebug(
"models.json content is not a valid models file envelope; starting from an empty registry",
);
return emptyModelsFile();
}
@@ -101,6 +133,10 @@ export function parseModelsFile(input: unknown): StoredModelsFile {
const provider = StoredProviderEntrySchema.safeParse(entry);
if (provider.success) {
providers[providerId] = provider.data;
} else {
sdkDebug(
`models.json: dropping invalid entry for provider=${providerId}`,
);
}
}
return { version: 1, providers };
@@ -114,7 +150,13 @@ export function readModelsFileSync(filePath: string): StoredModelsFile {
const raw = readFileSync(filePath, "utf8");
return parseModelsFile(JSON.parse(raw) as unknown);
} catch {
// Invalid or missing files fall back to an empty registry.
// The file exists but could not be read/parsed. Falling back to an
// empty registry is required for reads, but callers that then WRITE
// the empty state back would permanently destroy the user's data —
// leave a trace so that is diagnosable.
sdkDebug(
`models.json at ${filePath} exists but is unreadable or invalid JSON; treating as an empty registry`,
);
}
return emptyModelsFile();
}
@@ -125,19 +167,35 @@ export async function readModelsFile(
try {
const raw = await readFile(filePath, "utf8");
return parseModelsFile(JSON.parse(raw) as unknown);
} catch {
// Invalid or missing files fall back to an empty registry.
} catch (error) {
if ((error as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") {
sdkDebug(
`models.json at ${filePath} exists but is unreadable or invalid JSON; treating as an empty registry`,
);
}
}
return emptyModelsFile();
}
// Stage to a pid-unique temp file and rename into place (mirrors
// ProviderSettingsManager.write). Concurrent Cline processes (CLI, extension,
// hub) share models.json; a bare writeFileSync lets readers catch a partial
// file, which read paths treat as an empty registry — and the next
// read-modify-write would persist that loss.
export function writeModelsFileSync(
filePath: string,
state: StoredModelsFile,
): void {
mkdirSync(dirname(filePath), { recursive: true });
const parsed = StoredModelsFileSchema.parse(state);
writeFileSync(filePath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
const tempPath = `${filePath}.${process.pid}.tmp`;
try {
writeFileSync(tempPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
renameSync(tempPath, filePath);
} catch (error) {
rmSync(tempPath, { force: true });
throw error;
}
}
export async function writeModelsFile(
@@ -146,7 +204,14 @@ export async function writeModelsFile(
): Promise<void> {
await mkdir(dirname(filePath), { recursive: true });
const parsed = StoredModelsFileSchema.parse(state);
await writeFile(filePath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
const tempPath = `${filePath}.${process.pid}.tmp`;
try {
await writeFile(tempPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
await rename(tempPath, filePath);
} catch (error) {
await rm(tempPath, { force: true }).catch(() => {});
throw error;
}
}
export function toProviderModel(
@@ -245,17 +310,58 @@ function toStoredModelInfo(
const capabilities = new Set<ModelCapability>(
model?.capabilities ?? fallbackCapabilities ?? [],
);
if (model?.supportsVision) capabilities.add("images");
if (model?.supportsAttachments) capabilities.add("files");
if (model?.supportsReasoning) capabilities.add("reasoning");
if (model?.supportsVision !== undefined) {
if (model.supportsVision) capabilities.add("images");
else capabilities.delete("images");
}
if (model?.supportsAttachments !== undefined) {
if (model.supportsAttachments) capabilities.add("files");
else capabilities.delete("files");
}
if (model?.supportsReasoning !== undefined) {
if (model.supportsReasoning) capabilities.add("reasoning");
else capabilities.delete("reasoning");
}
const apiFormat = model?.isR1FormatRequired ? "r1" : model?.apiFormat;
const hasPricing =
model?.inputPrice !== undefined ||
model?.outputPrice !== undefined ||
model?.cacheReadsPrice !== undefined ||
model?.cacheWritesPrice !== undefined;
return {
id: modelId,
name: model?.name ?? modelId,
maxTokens: model?.maxTokens,
contextWindow: model?.contextWindow,
maxInputTokens: model?.maxInputTokens,
capabilities: capabilities.size > 0 ? [...capabilities] : undefined,
...(model?.maxTokens !== undefined ? { maxTokens: model.maxTokens } : {}),
...(model?.contextWindow !== undefined
? { contextWindow: model.contextWindow }
: {}),
...(model?.maxInputTokens !== undefined
? { maxInputTokens: model.maxInputTokens }
: {}),
...(capabilities.size > 0 ? { capabilities: [...capabilities] } : {}),
...(model?.temperature !== undefined
? { temperature: model.temperature }
: {}),
...(apiFormat !== undefined ? { apiFormat } : {}),
...(hasPricing
? {
pricing: {
...(model?.inputPrice !== undefined
? { input: model.inputPrice }
: {}),
...(model?.outputPrice !== undefined
? { output: model.outputPrice }
: {}),
...(model?.cacheReadsPrice !== undefined
? { cacheRead: model.cacheReadsPrice }
: {}),
...(model?.cacheWritesPrice !== undefined
? { cacheWrite: model.cacheWritesPrice }
: {}),
},
}
: {}),
};
}
@@ -439,6 +545,60 @@ export function registerCustomProvider(
});
}
/**
* Apply a single provider's updated models.json entry to the live @cline/llms
* registry. Unlike {@link ensureCustomProvidersLoadedSync}, which loads a
* models.json path at most once per process, this applies on every call so
* writes made after startup are reflected immediately: models removed from the
* entry are unregistered, and the remaining entry is (re-)registered.
*/
export function syncStoredProviderRegistration(
providerId: string,
previous: StoredProviderEntry | undefined,
next: StoredProviderEntry | undefined,
): void {
const nextModels = next?.models ?? {};
const removedModelIds = new Set<string>();
for (const [modelKey, model] of Object.entries(previous?.models ?? {})) {
if (Object.hasOwn(nextModels, modelKey)) {
continue;
}
const modelId = model.id?.trim() || modelKey.trim();
if (modelId) {
removedModelIds.add(modelId);
LlmsModels.unregisterModel(providerId, modelId);
}
}
if (!next) {
return;
}
const liveCollection = LlmsModels.getProviderCollectionSync(providerId);
registerCustomProvider(providerId, next);
// For entries with complete provider metadata, registerCustomProvider
// replaces the live collection with one built from models.json alone.
// Merge back models that came from other sources (generated catalog,
// providers.json settings), letting the fresh models.json entries win and
// dropping models removed by this write.
const registered = LlmsModels.getProviderCollectionSync(providerId);
if (liveCollection && registered && registered !== liveCollection) {
const preservedModels = Object.fromEntries(
Object.entries(liveCollection.models).filter(
([modelId]) => !removedModelIds.has(modelId),
),
);
LlmsModels.registerProvider({
...registered,
models: { ...preservedModels, ...registered.models },
});
}
}
/**
* Load models.json into the @cline/llms registry at most once per path per
* process; subsequent calls are no-ops. It does NOT re-read the file after
* writes use {@link syncStoredProviderRegistration} to reflect a write in
* the live registry.
*/
export function ensureCustomProvidersLoadedSync(
manager: ProviderSettingsManager,
): void {
@@ -71,6 +71,12 @@ describe("models registry parsing", () => {
alpha: {
name: "Alpha",
capabilities: ["reasoning"],
inputPrice: 1.25,
outputPrice: 3.5,
cacheReadsPrice: 0.25,
cacheWritesPrice: 1.5,
temperature: 0.2,
isR1FormatRequired: true,
},
},
},
@@ -94,7 +100,81 @@ describe("models registry parsing", () => {
});
await expect(
LlmsModels.getModelsForProvider("schema-provider"),
).resolves.toHaveProperty("alpha");
).resolves.toMatchObject({
alpha: {
pricing: {
input: 1.25,
output: 3.5,
cacheRead: 0.25,
cacheWrite: 1.5,
},
temperature: 0.2,
apiFormat: "r1",
},
});
});
it("drops invalid model numbers and lets explicit capability booleans win", async () => {
const parsed = parseModelsFile({
version: 1,
providers: {
"normalized-provider": {
provider: {
name: "Normalized Provider",
baseUrl: "https://normalized.example.invalid/v1",
},
models: {
alpha: {
maxTokens: -1,
contextWindow: Number.POSITIVE_INFINITY,
maxInputTokens: 0,
capabilities: ["images", "files", "reasoning", "tools"],
supportsVision: false,
supportsAttachments: false,
supportsReasoning: false,
inputPrice: Number.NaN,
outputPrice: 2,
cacheReadsPrice: -1,
temperature: -1,
apiFormat: "openai-responses",
isR1FormatRequired: false,
},
},
},
},
});
const entry = parsed.providers["normalized-provider"];
expect(entry?.models?.alpha).toEqual({
capabilities: ["images", "files", "reasoning", "tools"],
supportsVision: false,
supportsAttachments: false,
supportsReasoning: false,
outputPrice: 2,
apiFormat: "openai-responses",
isR1FormatRequired: false,
});
if (!entry) {
throw new Error("expected normalized provider entry");
}
registerCustomProvider("normalized-provider", entry);
await expect(
LlmsModels.getModelsForProvider("normalized-provider"),
).resolves.toMatchObject({
alpha: {
capabilities: ["tools"],
apiFormat: "openai-responses",
pricing: { output: 2 },
},
});
const model = (await LlmsModels.getModelsForProvider("normalized-provider"))
.alpha;
expect(model).not.toHaveProperty("maxTokens");
expect(model).not.toHaveProperty("contextWindow");
expect(model).not.toHaveProperty("maxInputTokens");
expect(model).not.toHaveProperty("temperature");
});
it("skips malformed provider entries while preserving valid providers", () => {
@@ -22,9 +22,8 @@ describe("getProviderConfigFields", () => {
expect(result.fields.apiKey).toEqual({
note: "Keep empty if no API key for local inference.",
});
expect(result.fields.baseUrl?.defaultValue).toBe(
"http://localhost:11434/v1",
);
// The native-API vendor appends /api itself; the default is a bare host.
expect(result.fields.baseUrl?.defaultValue).toBe("http://localhost:11434");
});
it("returns api-key auth with apiKey + baseUrl for LM Studio", () => {
+4
View File
@@ -45,6 +45,10 @@ export interface CoreModelConfig {
* Maximum output tokens per API call.
*/
maxTokensPerTurn?: number;
/**
* Sampling temperature per API call.
*/
temperature?: number;
}
export interface CoreRuntimeFeatures {
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/llms",
"version": "0.0.61",
"version": "0.0.62",
"description": "Config-driven SDK for selecting, extending, and instantiating LLM providers and models",
"repository": {
"type": "git",
@@ -60,6 +60,7 @@
"@opentelemetry/sdk-trace-node": "^2.6.1",
"@streamparser/json": "^0.0.21",
"ai": "^6.0.144",
"ai-sdk-ollama": "^3.8.8",
"ai-sdk-provider-claude-code": "^3.4.3",
"ai-sdk-provider-codex-cli": "^1.1.0",
"ai-sdk-provider-opencode-sdk": "^3.0.1",
+3 -1
View File
@@ -29,6 +29,7 @@ export {
registerProvider,
resetRegistry,
sortModelsByReleaseDate,
unregisterModel,
unregisterProvider,
VERCEL_OPENROUTER_MODEL_ID_ALIAS_RULES,
} from "./models";
@@ -64,8 +65,8 @@ export {
createHandler,
createHandlerAsync,
extractClinePassLimitMessage,
getClineOrgIndividualInferenceSubscriptionMessage,
getClineNotSubscribedMessage,
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassSubscriptionUrl,
getRegisteredHandler,
getRegisteredHandlerAsync,
@@ -79,6 +80,7 @@ export {
isClinePassLimitMessage,
isRegisteredHandlerAsync,
normalizeProviderId,
OLLAMA_DEFAULT_CONTEXT_WINDOW,
registerAsyncHandler,
registerHandler,
} from "./providers";
+1
View File
@@ -33,6 +33,7 @@ export {
registerModel,
registerProvider,
resetRegistry,
unregisterModel,
unregisterProvider,
} from "./providers/model-registry";
export {
+1
View File
@@ -1,3 +1,4 @@
export { OLLAMA_DEFAULT_CONTEXT_WINDOW } from "./providers/builtins";
export {
type ApiHandler,
BUILT_IN_PROVIDER,
+5 -1
View File
@@ -355,7 +355,6 @@ function toAiSdkMessages(
if (content.length > 0) {
normalizedMessages.push({ role: message.role, content });
} else if (!includeReasoning && skippedReasoning) {
continue;
} else if (message.role === "user" || message.role === "assistant") {
normalizedMessages.push({ role: message.role, content: "" });
}
@@ -1125,6 +1124,10 @@ async function createProviderModule(
const { createDifyProviderModule } = await import("./vendors/community");
return createDifyProviderModule(config);
}
case "ollama": {
const { createOllamaProviderModule } = await import("./vendors/ollama");
return createOllamaProviderModule(config, context);
}
case "sapaicore": {
const { createSapAiCoreProviderModule } = await import(
"./vendors/community"
@@ -1296,4 +1299,5 @@ export const createClaudeCodeProvider = createAiSdkProvider("claude-code");
export const createOpenAICodexProvider = createAiSdkProvider("openai-codex");
export const createOpenCodeProvider = createAiSdkProvider("opencode");
export const createDifyProvider = createAiSdkProvider("dify");
export const createOllamaProvider = createAiSdkProvider("ollama");
export const createSapAiCoreProvider = createAiSdkProvider("sapaicore");
@@ -63,6 +63,10 @@ async function loadFamilyFactory(
const module = await import("./ai-sdk");
return module.createDifyProvider;
}
case "ollama": {
const module = await import("./ai-sdk");
return module.createOllamaProvider;
}
case "sap-ai-core": {
const module = await import("./ai-sdk");
return module.createSapAiCoreProvider;
+17 -2
View File
@@ -53,6 +53,15 @@ const OPENROUTER_STICKY_SESSION_METADATA: GatewayProviderMetadata = {
},
};
/**
* Context window requested from Ollama when neither the resolved model nor
* the user's configuration supplies one. Matches the pre-SDK-migration
* handler default; deliberately larger than Ollama's 4096 server default,
* which cannot fit Cline's agentic prompts. Single source of truth the
* vendor, the VS Code session factory, and the settings UI all import this.
*/
export const OLLAMA_DEFAULT_CONTEXT_WINDOW = 32768;
export type ProviderFamily =
| "openai"
| "openai-compatible"
@@ -65,6 +74,7 @@ export type ProviderFamily =
| "openai-codex"
| "opencode"
| "dify"
| "ollama"
| "sap-ai-core";
export interface BuiltinSpec {
@@ -473,6 +483,7 @@ function inferClient(spec: BuiltinSpec): ProviderClient {
case "openai-codex":
case "opencode":
case "dify":
case "ollama":
case "sap-ai-core":
return "ai-sdk-community";
default:
@@ -922,11 +933,15 @@ const OPENAI_COMPATIBLE_SPECS: BuiltinSpec[] = [
id: "ollama",
name: "Ollama",
description: "Ollama Cloud and local LLM hosting",
family: "openai-compatible",
// Routed to the native Ollama API vendor (`vendors/ollama.ts`), not the
// OpenAI-compatible `/v1` endpoint: `/v1` ignores `options.num_ctx`, so
// models would always load with Ollama's 4096-token server default.
family: "ollama",
popular: 25,
capabilities: ["tools"],
defaultModelId: "",
apiKeyEnv: ["OLLAMA_API_KEY"],
defaults: { baseUrl: "http://localhost:11434/v1" },
defaults: { baseUrl: "http://localhost:11434" },
modelsSourceUrl: "http://localhost:11434/api/tags",
},
{
+73 -1
View File
@@ -1,5 +1,9 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createGatewayApiHandler, toGatewayRequestMessages } from "./compat";
import {
_testing,
createGatewayApiHandler,
toGatewayRequestMessages,
} from "./compat";
import { ClineNotSubscribedError } from "./errors";
import type { Message } from "./types";
@@ -773,3 +777,71 @@ describe("toGatewayRequestMessages — tool_result with images", () => {
expect(toolResult.output).toBe("raw string output");
});
});
describe("buildGatewayModels", () => {
const { buildGatewayModels } = _testing;
it("projects configured maxInputTokens onto the selected gateway model", () => {
const models = buildGatewayModels("ollama", {
providerId: "ollama",
modelId: "llama3.1",
maxInputTokens: 8192,
knownModels: {
"llama3.1": {
id: "llama3.1",
name: "llama3.1",
contextWindow: 131072,
},
},
});
expect(models).toEqual([
expect.objectContaining({
id: "llama3.1",
contextWindow: 8192,
maxInputTokens: 8192,
}),
]);
});
it("creates a definition for the selected model when it is not in knownModels", () => {
const models = buildGatewayModels("ollama", {
providerId: "ollama",
modelId: "minimax-m3:cloud",
maxInputTokens: 500000,
});
expect(models).toEqual([
expect.objectContaining({
id: "minimax-m3:cloud",
contextWindow: 500000,
maxInputTokens: 500000,
}),
]);
});
it("lets an explicit modelInfo override win over the generic limit", () => {
const models = buildGatewayModels("ollama", {
providerId: "ollama",
modelId: "llama3.1",
maxInputTokens: 8192,
modelInfo: { id: "llama3.1", contextWindow: 16384 },
});
expect(models).toEqual([
expect.objectContaining({
id: "llama3.1",
contextWindow: 16384,
}),
]);
});
it("returns undefined when there is nothing to project", () => {
expect(
buildGatewayModels("ollama", {
providerId: "ollama",
modelId: "llama3.1",
}),
).toBeUndefined();
});
});
+84 -8
View File
@@ -18,6 +18,7 @@ import {
createDifyProvider,
createGoogleProvider,
createMistralProvider,
createOllamaProvider,
createOpenAICodexProvider,
createOpenAICompatibleProvider,
createOpenAIProvider,
@@ -161,6 +162,8 @@ function resolveFactory(
return createOpenCodeProvider;
case "dify":
return createDifyProvider;
case "ollama":
return createOllamaProvider;
case "sapaicore":
return createSapAiCoreProvider;
default:
@@ -443,6 +446,77 @@ function buildGatewayRequest(
};
}
function buildGatewayModels(
providerId: string,
config: ProviderConfig,
): Omit<GatewayModelDefinition, "providerId">[] | undefined {
const definitions = new Map<
string,
Omit<GatewayModelDefinition, "providerId">
>();
for (const model of Object.values(config.knownModels ?? {})) {
const { providerId: _providerId, ...definition } = toGatewayModelDefinition(
providerId,
model,
);
definitions.set(definition.id, definition);
}
// Caller-configured limits are authoritative for the selected model —
// project them onto its gateway definition so the resolved model carries
// the right limits (e.g. Ollama's num_ctx derives from the resolved
// model's context window). `maxInputTokens` is where
// `ProviderSettings.contextWindow` lands via `toProviderConfig`; an
// explicit `modelInfo` override wins over the generic limit.
const configuredContextWindow =
typeof config.maxInputTokens === "number" &&
Number.isFinite(config.maxInputTokens) &&
config.maxInputTokens > 0
? Math.floor(config.maxInputTokens)
: undefined;
const modelInfo =
config.modelInfo && config.modelInfo.id === config.modelId
? config.modelInfo
: undefined;
if (config.modelId && (configuredContextWindow !== undefined || modelInfo)) {
const base = definitions.get(config.modelId) ?? {
id: config.modelId,
name: config.modelId,
};
const { providerId: _providerId, ...modelInfoDefinition } = modelInfo
? toGatewayModelDefinition(providerId, modelInfo)
: { providerId };
const definedOverrides = Object.fromEntries(
Object.entries(modelInfoDefinition).filter(([key, value]) => {
if (value === undefined) {
return false;
}
// toGatewayModelDefinition always emits a metadata object; drop
// it when it carries no actual values so it can't clobber the
// base definition's real metadata.
if (key === "metadata") {
return Object.values(value as Record<string, unknown>).some(
(entry) => entry !== undefined,
);
}
return true;
}),
);
definitions.set(config.modelId, {
...base,
...(configuredContextWindow !== undefined
? {
contextWindow: configuredContextWindow,
maxInputTokens: configuredContextWindow,
}
: {}),
...definedOverrides,
} as Omit<GatewayModelDefinition, "providerId">);
}
return definitions.size > 0 ? [...definitions.values()] : undefined;
}
function buildGatewayConfig(config: ProviderConfig) {
const providerId = normalizeProviderId(config.providerId);
return {
@@ -453,14 +527,7 @@ function buildGatewayConfig(config: ProviderConfig) {
timeoutMs: config.timeoutMs,
fetch: config.fetch,
defaultModelId: config.modelId,
models: config.knownModels
? Object.values(config.knownModels).map((model) => {
const definition = toGatewayModelDefinition(providerId, model);
const { providerId: _providerId, ...definitionWithoutProviderId } =
definition;
return definitionWithoutProviderId;
})
: undefined,
models: buildGatewayModels(providerId, config),
options: {
region: config.region ?? config.gcp?.region,
project: config.gcp?.projectId,
@@ -668,3 +735,12 @@ export async function createGatewayApiHandlerAsync(
}
})(config);
}
/**
* Internal test hook. Not part of the public API; production callers go
* through `createGatewayApiHandler(Async)`.
*/
export const _testing = {
buildGatewayConfig,
buildGatewayModels,
};
@@ -120,6 +120,8 @@ export interface TokenConfig {
maxInputTokens?: number;
/** Maximum output tokens (overrides model default) */
maxOutputTokens?: number;
/** Sampling temperature (overrides model default) */
temperature?: number;
}
/**
@@ -92,6 +92,10 @@ export function registerModel(
CUSTOM_MODELS.get(providerId)?.set(modelId, { ...info, id: modelId });
}
export function unregisterModel(providerId: string, modelId: string): boolean {
return CUSTOM_MODELS.get(providerId)?.delete(modelId) ?? false;
}
export function unregisterProvider(providerId: string): boolean {
CUSTOM_MODELS.delete(providerId);
return CUSTOM_PROVIDERS.delete(providerId);
@@ -17,6 +17,7 @@ export type AiSdkProviderOptionsTarget =
| "openai-codex"
| "opencode"
| "dify"
| "ollama"
| "sapaicore";
export type ProviderOptionSuppression = {
@@ -84,6 +85,8 @@ export function inferProviderOptionsTarget(
return "opencode";
case "dify":
return "dify";
case "ollama":
return "ollama";
case "sapaicore":
return "sapaicore";
default:
+240
View File
@@ -0,0 +1,240 @@
import type {
GatewayProviderContext,
GatewayResolvedProviderConfig,
} from "@cline/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
createOllamaProviderModule,
normalizeOllamaBaseUrl,
OLLAMA_DEFAULT_NUM_CTX,
OLLAMA_DEFAULT_TIMEOUT_MS,
readOllamaNumCtx,
readOllamaTimeoutMs,
withOllamaResponseTimeout,
} from "./ollama";
const createOllamaMock = vi.hoisted(() => vi.fn());
const ollamaModelMock = vi.hoisted(() =>
vi.fn((modelId: string, _settings?: unknown) => ({
specificationVersion: "v3",
provider: "ollama",
modelId,
})),
);
vi.mock("ai-sdk-ollama", () => ({
createOllama: createOllamaMock,
}));
describe("normalizeOllamaBaseUrl", () => {
it("passes a bare origin through (the ollama client appends /api itself)", () => {
expect(normalizeOllamaBaseUrl("http://localhost:11434")).toBe(
"http://localhost:11434",
);
expect(normalizeOllamaBaseUrl("https://ollama.com")).toBe(
"https://ollama.com",
);
});
it("strips a legacy OpenAI-compat /v1 suffix", () => {
expect(normalizeOllamaBaseUrl("http://localhost:11434/v1")).toBe(
"http://localhost:11434",
);
});
it("strips a native-API /api suffix", () => {
expect(normalizeOllamaBaseUrl("http://localhost:11434/api")).toBe(
"http://localhost:11434",
);
});
it("strips trailing slashes", () => {
expect(normalizeOllamaBaseUrl("http://localhost:11434/")).toBe(
"http://localhost:11434",
);
});
it("returns undefined for empty input", () => {
expect(normalizeOllamaBaseUrl(undefined)).toBeUndefined();
expect(normalizeOllamaBaseUrl(" ")).toBeUndefined();
});
});
describe("readOllamaNumCtx", () => {
it("reads the resolved model's context window", () => {
expect(readOllamaNumCtx(context({ contextWindow: 500000 }))).toBe(500000);
});
it("falls back to maxInputTokens when contextWindow is absent", () => {
expect(readOllamaNumCtx(context({ maxInputTokens: 128000 }))).toBe(128000);
});
it("falls back to the default for missing or invalid values", () => {
expect(readOllamaNumCtx(context({}))).toBe(OLLAMA_DEFAULT_NUM_CTX);
expect(readOllamaNumCtx(context({ contextWindow: 0 }))).toBe(
OLLAMA_DEFAULT_NUM_CTX,
);
expect(readOllamaNumCtx(context({ contextWindow: -1 }))).toBe(
OLLAMA_DEFAULT_NUM_CTX,
);
});
});
describe("readOllamaTimeoutMs", () => {
it("reads a configured timeout", () => {
expect(readOllamaTimeoutMs(config({ timeoutMs: 180000 }))).toBe(180000);
});
it("falls back to the default for missing or invalid values", () => {
expect(readOllamaTimeoutMs(config({}))).toBe(OLLAMA_DEFAULT_TIMEOUT_MS);
expect(readOllamaTimeoutMs(config({ timeoutMs: 0 }))).toBe(
OLLAMA_DEFAULT_TIMEOUT_MS,
);
expect(readOllamaTimeoutMs(config({ timeoutMs: -5 }))).toBe(
OLLAMA_DEFAULT_TIMEOUT_MS,
);
});
});
describe("withOllamaResponseTimeout", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("aborts when the response does not start within the timeout", async () => {
const hangingFetch = ((_input, init) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener("abort", () =>
reject(init.signal?.reason),
);
})) as typeof fetch;
const wrapped = withOllamaResponseTimeout(hangingFetch, 1000);
const pending = wrapped("http://localhost:11434/api/chat");
const assertion = expect(pending).rejects.toThrow(
"Ollama request timed out after 1 seconds",
);
await vi.advanceTimersByTimeAsync(1001);
await assertion;
});
it("does not abort once the response has started", async () => {
let requestSignal: AbortSignal | undefined;
const immediateFetch = (async (_input, init) => {
requestSignal = init?.signal ?? undefined;
return new Response("ok");
}) as typeof fetch;
const wrapped = withOllamaResponseTimeout(immediateFetch, 1000);
const response = await wrapped("http://localhost:11434/api/chat");
await vi.advanceTimersByTimeAsync(5000);
expect(response.ok).toBe(true);
// Timer was cleared on response start — streaming continues unaborted.
expect(requestSignal?.aborted).toBe(false);
});
it("propagates upstream aborts", async () => {
const hangingFetch = ((_input, init) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener("abort", () =>
reject(init.signal?.reason),
);
})) as typeof fetch;
const upstream = new AbortController();
const wrapped = withOllamaResponseTimeout(hangingFetch, 60_000);
const pending = wrapped("http://localhost:11434/api/chat", {
signal: upstream.signal,
});
const assertion = expect(pending).rejects.toThrow("user cancelled");
upstream.abort(new Error("user cancelled"));
await assertion;
});
});
describe("createOllamaProviderModule", () => {
beforeEach(() => {
createOllamaMock.mockReset();
createOllamaMock.mockReturnValue(ollamaModelMock);
ollamaModelMock.mockClear();
});
it("normalizes the base URL and passes the API key through", async () => {
const provider = await createOllamaProviderModule(
config({ baseUrl: "https://ollama.com/v1", apiKey: "ollama-key" }),
context({}),
);
provider.model("minimax-m3:cloud");
expect(createOllamaMock).toHaveBeenCalledWith(
expect.objectContaining({
baseURL: "https://ollama.com",
apiKey: "ollama-key",
}),
);
expect(ollamaModelMock).toHaveBeenCalledWith(
"minimax-m3:cloud",
expect.anything(),
);
});
it("requests num_ctx from the resolved model's context window", async () => {
const provider = await createOllamaProviderModule(
config({}),
context({ contextWindow: 65536 }),
);
provider.model("qwen3-coder:30b");
expect(ollamaModelMock).toHaveBeenCalledWith("qwen3-coder:30b", {
options: { num_ctx: 65536 },
});
});
it("requests the default num_ctx when the model has no context window", async () => {
const provider = await createOllamaProviderModule(config({}), context({}));
provider.model("llama3.1");
expect(ollamaModelMock).toHaveBeenCalledWith("llama3.1", {
options: { num_ctx: OLLAMA_DEFAULT_NUM_CTX },
});
});
it("omits baseURL and apiKey for a default local server", async () => {
await createOllamaProviderModule(config({}), context({}));
const call = createOllamaMock.mock.calls[0][0];
expect(call.baseURL).toBeUndefined();
expect(call.apiKey).toBeUndefined();
});
});
function config(
overrides: Partial<GatewayResolvedProviderConfig>,
): GatewayResolvedProviderConfig {
return {
providerId: "ollama",
...overrides,
};
}
function context(model: Record<string, unknown> = {}): GatewayProviderContext {
return {
provider: {
id: "ollama",
name: "Ollama",
defaultModelId: "",
models: [],
},
model: {
id: "minimax-m3:cloud",
name: "minimax-m3:cloud",
providerId: "ollama",
...model,
},
} as unknown as GatewayProviderContext;
}
+150
View File
@@ -0,0 +1,150 @@
// Ollama vendor backed by the native Ollama API (`/api/chat`) via the
// `ai-sdk-ollama` AI SDK provider (which wraps the official `ollama` client).
//
// Ollama cannot be driven through the generic OpenAI-compatible path
// (`/v1/chat/completions`): that endpoint ignores Ollama's proprietary
// `options.num_ctx` field, so every model loads with the server default
// context window (4096) regardless of the model's actual capacity or the
// user's configured context size. The native API accepts
// `options.num_ctx` per request; this boundary maps the provider-neutral
// model `contextWindow` onto it.
import type { LanguageModelV3 } from "@ai-sdk/provider";
import type {
GatewayProviderContext,
GatewayResolvedProviderConfig,
} from "@cline/shared";
import { wrapLanguageModel } from "ai";
import { createOllama } from "ai-sdk-ollama";
import { OLLAMA_DEFAULT_CONTEXT_WINDOW } from "../builtins";
import { ensureFetch, resolveApiKey } from "../http";
import { splitToolImagesMiddleware } from "../middleware/split-tool-images";
import type { ProviderFactoryResult } from "./types";
/** See {@link OLLAMA_DEFAULT_CONTEXT_WINDOW} — re-exported under the wire-format name. */
export const OLLAMA_DEFAULT_NUM_CTX = OLLAMA_DEFAULT_CONTEXT_WINDOW;
/**
* Normalize a configured base URL to the origin the `ollama` client expects
* as its `host` (the client appends `/api/...` itself).
*
* Users configure hosts like `http://localhost:11434` or
* `https://ollama.com`; configs saved by the 4.0.0 OpenAI-compatible
* routing may carry a `/v1` suffix, and native-API configs an `/api` one.
*/
export function normalizeOllamaBaseUrl(
baseUrl: string | undefined,
): string | undefined {
const trimmed = baseUrl?.trim().replace(/\/+$/, "");
if (!trimmed) {
return undefined;
}
return trimmed.replace(/\/(?:v1|api)$/, "");
}
/**
* Resolve the `num_ctx` to request from the resolved model's context window.
* `num_ctx` stays an Ollama wire-format detail: callers express intent through
* the provider-neutral model `contextWindow` (from the model catalog or the
* user's configured context window), and this boundary maps it onto the wire.
*/
export function readOllamaNumCtx(context: GatewayProviderContext): number {
const value = context.model?.contextWindow ?? context.model?.maxInputTokens;
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return Math.floor(value);
}
return OLLAMA_DEFAULT_NUM_CTX;
}
/**
* Time to wait for the response to start when no timeout is configured.
* Matches the pre-SDK-migration Ollama handler default.
*/
export const OLLAMA_DEFAULT_TIMEOUT_MS = 30_000;
/**
* Read the configured request timeout, mirroring the legacy handler's
* `requestTimeoutMs || 30000` (zero/invalid values fall back to the default).
*/
export function readOllamaTimeoutMs(
config: GatewayResolvedProviderConfig,
): number {
const timeoutMs = config.timeoutMs;
if (
typeof timeoutMs === "number" &&
Number.isFinite(timeoutMs) &&
timeoutMs > 0
) {
return Math.floor(timeoutMs);
}
return OLLAMA_DEFAULT_TIMEOUT_MS;
}
/**
* Wrap a fetch so the *response* must start within `timeoutMs`. Once headers
* arrive the timer is cleared streaming the body is never interrupted.
* Mirrors the legacy handler, which raced the chat call (stream start)
* against a timeout rather than bounding the whole generation.
*/
export function withOllamaResponseTimeout(
baseFetch: typeof fetch,
timeoutMs: number,
): typeof fetch {
return (async (input, init) => {
const timeoutController = new AbortController();
const timer = setTimeout(
() =>
timeoutController.abort(
new Error(
`Ollama request timed out after ${timeoutMs / 1000} seconds`,
),
),
timeoutMs,
);
// AbortSignal.any keeps upstream cancellation live for the entire
// request (including body streaming after the timer is cleared) and
// cleans up its own listeners — no manual listener management.
const upstreamSignal = init?.signal;
const signal = upstreamSignal
? AbortSignal.any([upstreamSignal, timeoutController.signal])
: timeoutController.signal;
try {
return await baseFetch(input, { ...init, signal });
} finally {
clearTimeout(timer);
}
}) as typeof fetch;
}
export async function createOllamaProviderModule(
config: GatewayResolvedProviderConfig,
context: GatewayProviderContext,
): Promise<ProviderFactoryResult> {
// An API key is only needed for Ollama Cloud (ollama.com); local servers
// accept unauthenticated requests, so a missing key is not an error.
// `ai-sdk-ollama` turns `apiKey` into an `Authorization: Bearer` header.
const apiKey = await resolveApiKey(config);
const baseURL = normalizeOllamaBaseUrl(config.baseUrl);
const provider = createOllama({
...(baseURL ? { baseURL } : {}),
...(apiKey ? { apiKey } : {}),
...(config.headers ? { headers: config.headers } : {}),
fetch: withOllamaResponseTimeout(
ensureFetch(config.fetch),
readOllamaTimeoutMs(config),
),
});
const numCtx = readOllamaNumCtx(context);
return {
// `splitToolImagesMiddleware` for the same reason as the
// OpenAI-compatible vendor: the downstream converter stringifies
// multimodal tool-result content, losing image bytes.
model: (modelId) =>
wrapLanguageModel({
model: provider(modelId, {
options: { num_ctx: numCtx },
}) as LanguageModelV3,
middleware: splitToolImagesMiddleware,
}),
};
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/sdk",
"description": "Cline SDK - user-facing alias for @cline/core",
"version": "0.0.61",
"version": "0.0.62",
"repository": {
"type": "git",
"url": "https://github.com/cline/cline",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/shared",
"version": "0.0.61",
"version": "0.0.62",
"description": "Shared utilities, types, and schemas for Cline packages",
"repository": {
"type": "git",
+5
View File
@@ -713,6 +713,10 @@ export interface AgentConfig {
* Maximum output tokens per API call
*/
maxTokensPerTurn?: number;
/**
* Sampling temperature per API call
*/
temperature?: number;
/**
* Timeout for each API call in milliseconds
* @default 180000 (3 minutes)
@@ -888,6 +892,7 @@ export const AgentConfigSchema = z.object({
maxIterations: z.number().positive().optional(),
maxParallelToolCalls: z.number().int().positive().default(8),
maxTokensPerTurn: z.number().positive().optional(),
temperature: z.number().nonnegative().optional(),
apiTimeoutMs: z.number().positive().default(180000),
userFileContentLoader: z
.function()