mirror of
https://github.com/cline/cline.git
synced 2026-09-05 05:02:27 +08:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ea34be611 | |||
| e72bc3cd14 | |||
| 9c907af826 | |||
| e8d3d82522 | |||
| 7f9d2e96d9 | |||
| d618f8073a | |||
| eb21ba583c | |||
| f29c25395c | |||
| 84c9b587a6 |
@@ -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"
|
||||
@@ -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}"
|
||||
|
||||
@@ -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,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": {
|
||||
|
||||
@@ -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`.
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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?.();
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
-1
@@ -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],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/agents",
|
||||
"version": "0.0.61",
|
||||
"version": "0.0.62",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -33,6 +33,7 @@ export {
|
||||
registerModel,
|
||||
registerProvider,
|
||||
resetRegistry,
|
||||
unregisterModel,
|
||||
unregisterProvider,
|
||||
} from "./providers/model-registry";
|
||||
export {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { OLLAMA_DEFAULT_CONTEXT_WINDOW } from "./providers/builtins";
|
||||
export {
|
||||
type ApiHandler,
|
||||
BUILT_IN_PROVIDER,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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,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,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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user