Compare commits

..

31 Commits

Author SHA1 Message Date
Max Paulus 🥪 dd3056e519 fix clinerules detection when using the change_directory tool 2026-02-23 21:44:30 -08:00
Max Paulus 🥪 a702dac0ee refactor chatview to use useFilteredSlashCommand hook 2026-02-23 21:44:29 -08:00
Max Paulus 🥪 740a557dc1 clean up autoapprove and tool executor 2026-02-23 21:44:29 -08:00
Max Paulus 🥪 5d9de9e837 add a "change_directory" tool to cline for CLI usage only
- this tool changes the working directory for the current task
2026-02-23 21:44:29 -08:00
Bee ea65383e16 chore: replace baseUrl with explicit relative paths in tsconfig files (#9508)
* chore: replace baseUrl with explicit relative paths in tsconfig files

Remove `baseUrl: "."` from tsconfig configurations and update all path aliases to use explicit relative paths (e.g., `./src/*` instead of `src/*`). This makes path resolution more explicit and avoids potential ambiguity in module resolution across the main project and webview-ui configurations.

* update package-lock.json
2026-02-23 18:00:09 -08:00
Ara b97d1487a7 Release V3.67.0 (#9509)
* Release V3.67.0

Bump version from 3.66.0 to 3.67.0 in package.json and
package-lock.json. Add changelog entry for v3.67.0 covering new
features (subagent skills, AgentConfigLoader, Responses API, websocket
preconnect, CLI /q command), bug fixes (reasoning delta crash, OpenAI
tool ID, auth checks, Gemini 3.1 Pro), and other changes. Update
WhatsNewItems fallback banners to reflect current promotions.

* Fixing stuff
2026-02-23 17:25:30 -08:00
Robin Newhouse 091cf945e4 Move PR skill to .agents/skills (#9505)
* Move PR skill to .agents/skills and add changeset guidance

* Remove changeset guidance from PR skill
2026-02-23 16:31:32 -08:00
Bee df4f551ba7 feat: add support for skills and optional modelId in subagent config (ENG-1564) (#9502)
* refactor: consolidate subagent request usage tracking into state object

Replace scattered per-request token tracking variables with a structured
`SubagentUsageState` interface containing `currentRequest` and
`lastRequest` states. This improves code organization by grouping related
token metrics (input, output, cache write/read, total tokens, cost) into
a cohesive `SubagentRequestUsageState` object, reducing variable sprawl
and making the usage lifecycle (current → last) more explicit.

* feat(subagent): add support for skills and optional modelId in agent config

- Update `AgentBaseConfigSchema` and `AgentConfigFrontmatterSchema` to include an optional `skills` field and make `modelId` optional.
- Implement `parseSkills` and `normalizeSkillName` in `AgentConfigLoader` to handle skill parsing from YAML frontmatter.
- Update `SubagentBuilder` to provide access to configured skills.
- Modify `SubagentRunner` to filter available skills based on the agent's configuration, falling back to all available skills if none are specified.
- Update host retrieval to use `HostRegistryInfo` instead of `HostProvider`.

This allows subagents to be restricted to specific skills and provides more flexibility in model configuration.

* update unit test
2026-02-23 16:23:36 -08:00
Ara 88da4ddf89 feat: fetch featured models from backend with local fallback (#9495)
* feat(cli): fetch featured models from backend with local fallback

- Add async getFeaturedModelsForCline() to fetch models via controller
- Load featured models dynamically in AuthView with useEffect
- Update FeaturedModelPicker to accept featuredModels as optional prop
- Refactor helper functions to accept models parameter for flexibility
- Keep local hardcoded models as fallback when backend fetch fails

* Fixing stuff

* Fixing stuff

* Fixing stuff
2026-02-23 16:13:51 -08:00
CandiedUniverse 93eb607e6d Remove all traces of changeset-converter.yml GitHub Action and npm run changeset (#9506) 2026-02-23 15:53:56 -08:00
Tony Loehr 810b5b78f5 added mcp enterprise configuration details (#9501)
Co-authored-by: Juan Pablo Flores <juan@cline.bot>
2026-02-23 15:51:05 -08:00
Tony Loehr 38ea422f6a Sso video (#9496)
* docs: add CVE scan sample to navigation, fix accordion labels, clarify --yolo flag

* added sso video

* Remove CVE scanner changes from SSO video PR
2026-02-23 15:29:48 -08:00
Robin Newhouse a7a35c0138 ci: add automatic retries for smoke test jobs (#9503) 2026-02-23 14:55:38 -08:00
Bee 01547ba1f4 feat: add AgentConfigLoader for file-based agent configs (ENG-1547) (#9245)
* refactor: centralize tool handler registration and filter by allowed tools

- Create centralized toolHandlersMap for all tool handler instantiation
- Add registerToolHandlers method to register only allowed tools from config
- Filter subagent tools to only include allowed tools from allowedTools config
- Remove scattered tool handler registration logic in favor of single source of truth
- Improve maintainability by consolidating tool handler creation in one place

This refactoring ensures subagents only have access to explicitly allowed tools
and makes the tool registration process more maintainable and consistent.

It also improves separation of concerns by having the coordinator manage all tool handler registration, while the executor focuses on orchestration. The allowedTools parameter enables runtime filtering of available tools for different contexts.

Also update PromptRegistry to load synchronous and simplify variant lookup

- Convert async load() to synchronous, called in constructor as both loadVariants and loadComponents are not async functions
- Remove health check logic and loaded state tracking
- Extract getVariant() method with proper generic fallback
- Add getComponents() accessor and simplify component loading
- Convert variant/component loaders from async to synchronous
- Remove unnecessary await calls throughout the codebase
- Add PromptRegistry tests for variant resolution and components

* fix test

* feat: add AgentConfigLoader for file-based agent configs

Add AgentConfigLoader singleton to manage agent configurations loaded from
YAML files in the agents directory. Supports hot-reloading via file watcher,
validates config schema with Zod, and integrates with extension lifecycle
(StateManager initialization and tearDown disposal).

* add tests

* add missing export

* update tests

* feat(tools): implement dynamic tool registration for subagents

Updates the tool system to support dynamically registered subagents as individual tools.

- Modifies `ClineToolSet` to generate specific tool definitions for configured subagents via `AgentConfigLoader`.
- Updates `parseAssistantMessageV2` to use `getToolUseNames()` instead of a static list, enabling the parser to recognize dynamic tool tags.
- Replaces the generic `USE_SUBAGENTS` tool with specific subagent instances when available in the system prompt context.

* update config path and refine tool descriptions

- Relocate the subagent configuration directory from `~/.cline/data/agents` to `~/Documents/Cline/Agents` to improve user accessibility.
- Update subagent tool descriptions and parameter instructions in the system prompt to be more descriptive and helpful for the model.

* revert unrelated changes

* revert unrelated changes

* update unit test

* fix: await AgentConfigLoader initialization before StateManager completes

Ensure agent configs are fully loaded during StateManager initialization
by awaiting the `ready()` promise. Previously, `AgentConfigLoader` was
instantiated without waiting for the initial load to complete, causing
potential race conditions where configs might not be available when
needed.

- Add `initialLoadPromise` field to track the async initial load
- Expose a `ready()` method to allow callers to await initialization
- Await `AgentConfigLoader.getInstance().ready()` in StateManager

* set previousRequestTotalTokens
2026-02-23 13:26:41 -08:00
Robin Newhouse 7f1632f09f fix: prevent reasoning delta crash on usage-only stream chunks (#9432)
* fix: guard missing delta in reasoning streams

* test: isolate litellm prompt cache call-count assertions
2026-02-23 12:29:34 -08:00
Han Wang 1b0ab3d01b sambanova provider: update models list (#9479)
* Add changeset

* Allow temperature config

* update issues summary

* Update SambaNova docs

* Update list of sambanova models

* Update minimax m2.5

* remove 2 models

* Remove residual
2026-02-23 11:27:36 -08:00
Chaitanya Eranki dce0902596 Oca Messages API implementation for new Claude Models (#9447)
* Made messages api changes

* Made changes

* Added changes

* Made maxTokens point to the right thing

* Removed deprecated max_tokens field

* Reverted the change

* Making an additional change to not cause any issues with chat completions

* reverting changes so we can make them in the backend

* Added changeset

* Fixed changes based on AI comments
2026-02-23 11:20:05 -08:00
Bee 9e7a30bd34 feat: preconnect websocket to reduce response latency (#9458)
Warm up the OpenAI WebSocket connection early in WebSocket mode to avoid handshake latency on the first response.create call. This introduces a responsesWsReadyPromise to track the connection state and prevent duplicate connection attempts while the initial connection is in flight.
2026-02-23 11:17:03 -08:00
Bee 2b1b1d1cf2 fix: restrict OpenAI tool ID transformation to native provider (#9459)
* fix: restrict OpenAI tool ID transformation to native provider

Update `convertToOpenAiMessages` and `transformToolCallId` to only apply tool ID transformations when the provider is explicitly set to `openai-native`. This prevents unintended ID modifications for other providers (like OpenRouter or local LLMs) that use the OpenAI format but may have different tool ID requirements or already provide compatible IDs.

* update tests

* transformToolCallIdForNativeApi
2026-02-23 11:16:55 -08:00
Max fcf3792f63 fix auth check for acp mode (#9491)
- acp code wasn't using the proper 'isAuthConfigured' method for
checking auth status

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-02-23 09:41:56 -08:00
shey-cline 0e833ade82 Add /q command to quit CLI (#9400)
* init

* changeset

* Apply suggestion from @greptile-apps[bot]

oops

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

* add tests

* add /q info to help panel

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-02-23 07:37:34 -08:00
Ara 4455db5198 Pull Cline's recommended from internal endpoint (#9376)
* feat(cline): fetch recommended models from API endpoint

* Adding 1m

* Adding 1m

* Adding 1m

* fix: harden model tag label handling and tab init

* fix(models): add retry-safe fetch, id canonicalization, and shared filtering

* chore: trigger PR head refresh

* refactor(models): remove canonical alias map for OpenRouter IDs

* refactor(webview): remove redundant cline fetch on mount

* Adding 1m
2026-02-22 22:28:55 -08:00
Bee 03ab2968a6 feat: responses api for openai native provider (#9411)
* fix: openai native provider token usage mapping

- Add `store` parameter support to OpenAI native provider options to allow persisting completions.
- Fix incorrect mapping of `cached_tokens` and `reasoning_tokens` in usage statistics.
- Include `thoughtsTokenCount` in the final usage report to track reasoning model performance and costs.

* feat: add websocket support for OpenAI Responses API

This commit introduces WebSocket support for the OpenAI Native provider's Responses API, providing an alternative to the standard HTTP streaming.

- Implement `createResponseStreamWebsocket` in `OpenAiNativeHandler` with a fallback to HTTP on failure.
- Refactor `OpenAiNativeHandler` to modularize tool mapping and parameter construction for the Responses API.
- Update `OcaHandler` to explicitly disable `previousResponseId` when using the Responses API and add validation for model information.
- Integrate `undici` WebSocket for better compatibility in the extension environment.

* disablePreviousResponseId

* feat: add timestamp to conversation messages for response chaining

Add `ts` field to `ClineStorageMessage` to track when messages were
created. Use this timestamp to enforce a 23-hour validity window when
chaining OpenAI responses via `previousResponseId`, since the API only
retains responses for 24 hours. Also fix non-null assertion operators
in tests to use optional chaining for safer access.

* add OpenAI Responses Websocket Mode ApiFormat support

- Add `OPENAI_RESPONSES_WEBSOCKET_MODE` to the `ApiFormat` enum in proto definitions.
- Update `OpenAiNativeHandler` to use the new API format for determining when to use websocket mode, replacing previous environment-based logic.
- Refactor tool mapping for OpenAI Responses to support strict mode and correctly handle null parameters.
- Ensure the `store` option is disabled when `previous_response_id` is present in websocket mode.
- Bump version to 2.4.1 and update package dependencies.

* use abortController

* add support for websocket mode to openai-codex

* set behind feature flag
2026-02-20 17:17:54 -08:00
Max a0d52d4d59 cli yolo mode should not persist yolo setting to disk ever (#9370)
- added a method to StateManager, setSessionOverride, which overrides
state settings while the statemanager lives in memory

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-02-20 11:16:29 -08:00
Max 75fbeb4aad fix cline auth with acp flag (#9405)
- was missing a check for "cline:clineAccountId" in the isAuthed method
of acp agent

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-02-20 11:15:45 -08:00
Bee 70db6bde34 fix: inline focus-chain slider within its feature row (#9444)
* fix: inline focus-chain slider within its feature row

Moves the focus-chain reminder interval `SettingsSlider` from a
standalone element rendered after all experimental feature rows to
being rendered directly beneath the focus-chain `FeatureRow`. The
slider now renders conditionally when `feature.id === "focus-chain"`
and the feature is enabled, improving UI cohesion and making the
relationship between the toggle and its configuration more explicit.

Additionally:
- Relocates focus-chain from `experimentalFeatures` to `agentFeatures`
- Removes the `isExperimental` prop and "Experimental:" label badge
  from `FeatureRow` and related feature toggle definitions
- Simplifies `SettingsSlider` markup by removing the wrapper card
  styling, making it suitable for inline embedding
  - Removes unused line from common.ts

* nestedKey
2026-02-20 10:58:46 -08:00
Saoud Rizwan 02c2601e0e fix(gemini): add 3.1 pro while keeping 3.0 compatibility (#9438) 2026-02-20 09:18:40 -08:00
Juan Pablo Flores 94692b5091 docs: add MCP support documentation for Cline CLI (#9390) 2026-02-20 09:39:02 -06:00
Robin Newhouse a2794c680f fix(evals): restore missing smoke eval npm scripts (#9429) 2026-02-19 14:49:27 -08:00
CandiedUniverse 6d3f8e1d5d fix(release-eng): Pin VSCode nightly build to node version 22 (#9423)
* fix(release-eng): Pin VSCode nightlybuild to node version 22

* fix(release-eng): Pin publish.yml GitHub workflow to node version 22
2026-02-19 12:04:14 -08:00
cryptoque 3e5847890b feat: add dynamic flag to adjust banner cache duration (#9421) 2026-02-19 11:03:50 -08:00
154 changed files with 5075 additions and 3345 deletions
@@ -1,6 +1,6 @@
---
name: create-pull-request
description: Create a GitHub pull request following project conventions. Use when the user asks to create a PR, submit changes for review, or open a pull request. Handles commit analysis, branch management, and PR creation using the gh CLI tool.
description: Create a GitHub pull request following project conventions. Use when the user asks to create a PR, submit changes for review, or open a pull request. Handles commit analysis, branch management, PR template usage, and PR creation using the gh CLI tool.
---
# Create Pull Request
-8
View File
@@ -1,8 +0,0 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
+5
View File
@@ -0,0 +1,5 @@
---
"cline": minor
---
Adds Messages API support to Oracle Code Assist, adding functionality for Claude models
-11
View File
@@ -1,11 +0,0 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.0.5/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}
+5
View File
@@ -0,0 +1,5 @@
---
"cline": patch
---
Add /q command to quit CLI
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fix a streaming crash when a chunk has usage data but no `delta` by guarding reasoning field checks in provider handlers. Add regression tests for OpenRouter, Cline, Vercel AI Gateway, and Fireworks handlers to cover usage-only chunks.
+4
View File
@@ -0,0 +1,4 @@
"claude-dev": patch
---
Add missing smoke evaluation npm scripts so documented commands like `npm run eval:smoke:run` work from the repository root.
+5
View File
@@ -0,0 +1,5 @@
---
"cline": patch
---
Add automatic retries (up to 3 attempts) for smoke test CI jobs to reduce flaky failures
+5
View File
@@ -0,0 +1,5 @@
---
"cline": patch
---
fix acp auth check so acp mode can be used with more providers
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Update SambaNova Provider models list and add temperature for models
-26
View File
@@ -1,26 +0,0 @@
changesDir: .changes
unreleasedDir: unreleased
headerPath: header.tpl.md
changelogPath: CHANGELOG.md
versionExt: md
versionFormat: '## {{.Version}} - {{.Time.Format "2006-01-02"}}'
kindFormat: "### {{.Kind}}"
changeFormat: "* {{.Body}}"
kinds:
- label: Added
auto: minor
- label: Changed
auto: major
- label: Deprecated
auto: minor
- label: Removed
auto: major
- label: Fixed
auto: patch
- label: Security
auto: patch
newlines:
afterChangelogHeader: 1
beforeChangelogVersion: 1
endOfVersion: 1
envPrefix: CHANGIE_
+1 -1
View File
@@ -14,7 +14,7 @@ This file is the secret sauce for working effectively in this codebase. It captu
## Miscellaneous
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `npm run compile`, not `npm run build`).
- When creating PRs, if the change is user-facing and significant enough to warrant a changelog entry, run `npm run changeset` and create a patch changeset. Never create minor or major version bumps. Skip changesets for trivial fixes, internal refactors, or minor UI tweaks that users wouldn't notice.
- When creating PRs, contributors should not create changelog-entry files. Maintainers handle release versioning and changelog curation during the release process.
- When adding new feature flags, see this PR as a reference https://github.com/cline/cline/pull/7566
- Additional instructions about making requests: @.clinerules/network.md
+1 -1
View File
@@ -19,7 +19,7 @@ Review and address all comments on the current branch's PR.
- Inline comments: `gh api repos/{owner}/{repo}/pulls/{pr_number}/comments`
- General comments: `gh pr view {pr_number} --json comments,reviews`
4. Present a summary of all comments with your recommendation for each (apply, skip, or respond). Ignore bot noise (changeset-bot, CI status, etc.).
4. Present a summary of all comments with your recommendation for each (apply, skip, or respond). Ignore bot noise (release automation, CI status, etc.).
5. **Wait for my approval** before proceeding.
-549
View File
@@ -1,549 +0,0 @@
The goal of this workflow is to take a changeset for a release of Cline, an autonomous coding agent extension that plugs right into your IDE, and write the updated announcement component, and the updated changelog.
For reference, here are some examples of how we converted previous changesets to announcement components / changelogs.
- 3.14
<changeset>
This PR was opened by the Changesets release GitHub action. When you're ready to do a release, you can merge this and publish to npm yourself or setup this action to publish automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated.
Releases
claude-dev@3.14.0
Minor Changes
77c9863: create clinerules folder if its currently a file and creating new rule
0ffb7dd: disabling shift hint for now & improving tooltip behavior
79b76fd: Add support for custom model ID in AWS Bedrock provider, enabling use of Application Inference Profile.
eb6e481: Full support for LaTeX rendering
df37f29: Add support for custom API request timeout. Previously, timeouts were hardcoded to 30 seconds for providers like Ollama or 15 seconds for OpenRouter and Cline. Now users can set a custom timeout value in milliseconds through the settings interface.
e4d26be: allow cursorrules and windsurfrules
c5de50f: Fix Handle @withRetry() SyntaxError when running extension locally issue
61d2f42: enabled pricing calculation for gemini and vertex + more robust caching & cache tracking for gemini & vertex
aed152b: add truncation notice when truncating manually
2fe2405: Migrate Cline Tools Section to new docs
19cc8bc: Add a timeout setting for the terminal connection, allowing users to adjust this if they are having timeout issues
03d4410: Added copy button to code blocks.
c78fe23: addressed race condition in terminal command usage
91e222f: add checkpoints after more messages
14230e7: add newrule slash command
1c7d33a: Add remote config with posthog allowing for disabling new features until they're reading, making for a better developer experience.
4196c14: add cache ui for open router and cline provider
d97424f: showing expanded task by default
5294e78: Refactor to not pass a message for showing the MCP View from the servers modal
70cc437: Fix Windows path issue: Correct handling of import.meta.url to avoid leading slash in pathname
4b697d8: Migrate the addRemoteServer to protobus
Patch Changes
c63d9a1: updated drag and drop text to say "drop" instead of "drag"
459adf0: Add markdown copy to chat
74ec823: Minor UX improvement to drag and drop ux
b0961f4: Remove linear pull request action
e9ce384: searchCommits protobus migration
5802b68: createRuleFile protobus migration
df7f9fc: Add dependsOn to more blocks in the tasks.json
41ae732: Fix for git commit mentions in repos with no git commits
7e78445: Adding args to allow Cursor to open workspaces (for checkpoint testing/development)
bdfda6f: feat(bedrock): Introduce Amazon Nova Premier
65243ad: Introduce UI library for future UI development
4565e06: checkIsImageURL migrated to protobus
5a8e9d8: protobus migration for openImage
deeda6e: Lowering Gemini cache TTL time
db0b022: Adding UI to show openrouter balance next to provider
4650ffa: deleteRuleFile protobus migration
d4bd755: fix cost calculation
</changeset>
<changelog>
## [3.14.0]
- Add UI to show openrouter balance next to provider
- Add support for custom model ID in AWS Bedrock provider, enabling use of Application Inference Profile (Thanks @clicube!)
- Add more robust caching & cache tracking for gemini & vertex providers
- Add support for LaTeX rendering
- Add support for custom API request timeout. Timeouts were 15-30s, but can now be configured via settings for OpenRouter/Cline & Ollama (Thanks @WingsDrafterwork!)
- Add truncation notice when truncating manually
- Add a timeout setting for the terminal connection, allowing users to set a time to wait for terminal startup
- Add copy button to code blocks
- Add copy button to markdown blocks (Thanks @weshoke!)
- Add checkpoints to more messages
- Add slash command to create a new rules file (/newrule)
- Add cache ui for open router and cline provider
- Add Amazon Nova Premier model to Bedrock (Thanks @watany!)
- Add support for cursorrules and windsurfrules
- Add support for batch history deletion (Thanks @danix800!)
- Improve Drag & Drop experience
- Create clinerules folder creating new rule if it's needed
- Enable pricing calculation for gemini and vertex providers
- Refactor message handling to not show the MCP View of the server modal
- Migrate the addRemoteServer to protobus (Thanks @DaveFres!)
- Update task header to be expanded by default
- Update Gemini cache TTL time to 15 minutes
- Fix race condition in terminal command usage
- Fix to correctly handle `import.meta.url`, avoiding leading slash in pathname for Windows (Thanks @DaveFres!)
- Fix @withRetry() decoration syntax error when running extension locally (Thanks @DaveFres!)
- Fix for git commit mentions in repos with no git commits
- Fix cost calculation (Thanks @BarreiroT!)
</changelog>
<announcement-component>
const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
const minorVersion = version.split(".").slice(0, 2).join(".") // 2.0.0 -> 2.0
return (
<div style={containerStyle}>
<VSCodeButton appearance="icon" onClick={hideAnnouncement} style={closeIconStyle}>
<span className="codicon codicon-close"></span>
</VSCodeButton>
<h3 style={h3TitleStyle}>
🎉{" "}New in v{minorVersion}
</h3>
<ul style={ulStyle}>
<li>
<b>Gemini prompt caching:</b> Gemini and Vertex providers now support prompt caching and price tracking for
Gemini models.
</li>
<li>
<b>Copy Buttons:</b> Buttons were added to Markdown and Code blocks that allow you to copy their contents
easily.
</li>
<li>
<b>/newrule command:</b> New slash command to have cline write your .clinerules for you based on your
workflow.
</li>
<li>
<b>Drag and drop improvements:</b> Don't forget to hold shift while dragging files!
</li>
<li>Added more checkpoints across the task, allowing you to restore from more than just file changes.</li>
<li>Added support for rendering LaTeX in message responses. (Try asking Cline to show the quadratic formula)</li>
</ul>
<Accordion isCompact className="pl-0">
<AccordionItem
key="1"
aria-label="Previous Updates"
title="Previous Updates:"
classNames={{
trigger: "bg-transparent border-0 pl-0 pb-0 w-fit",
title: "font-bold text-(--vscode-foreground)",
indicator:
"text-(--vscode-foreground) mb-0.5 -rotate-180 data-[open=true]:-rotate-90 rtl:rotate-0 rtl:data-[open=true]:-rotate-90",
}}>
<ul style={ulStyle}>
<li>
<b>Global Cline Rules:</b> store multiple rules files in Documents/Cline/Rules to share between
projects.
</li>
<li>
<b>Cline Rules Popup:</b> New button in the chat area to view workspace and global cline rules files
to plug and play specific rules for the task
</li>
<li>
<b>Slash Commands:</b> Type <code>/</code> in chat to see the list of quick actions, like starting a
new task (more coming soon!)
</li>
<li>
<b>Edit Messages:</b> You can now edit a message you sent previously by clicking on it. Optionally
restore your project when the message was sent!
</li>
</ul>
</AccordionItem>
</Accordion>
{/*
// Leave this here for an example of how to structure the announcement
<ul style={{ margin: "0 0 8px", paddingLeft: "12px" }}>
<li>
OpenRouter now supports prompt caching! They also have much higher rate limits than other providers,
so I recommend trying them out.
<br />
{!apiConfiguration?.openRouterApiKey && (
<VSCodeButtonLink
href={getOpenRouterAuthUrl(vscodeUriScheme)}
style={{
transform: "scale(0.85)",
transformOrigin: "left center",
margin: "4px -30px 2px 0",
}}>
Get OpenRouter API Key
</VSCodeButtonLink>
)}
{apiConfiguration?.openRouterApiKey && apiConfiguration?.apiProvider !== "openrouter" && (
<VSCodeButton
onClick={() => {
vscode.postMessage({
type: "apiConfiguration",
apiConfiguration: { ...apiConfiguration, apiProvider: "openrouter" },
})
}}
style={{
transform: "scale(0.85)",
transformOrigin: "left center",
margin: "4px -30px 2px 0",
}}>
Switch to OpenRouter
</VSCodeButton>
)}
</li>
<li>
<b>Edit Cline's changes before accepting!</b> When he creates or edits a file, you can modify his
changes directly in the right side of the diff view (+ hover over the 'Revert Block' arrow button in
the center to undo "<code>{"// rest of code here"}</code>" shenanigans)
</li>
<li>
New <code>search_files</code> tool that lets Cline perform regex searches in your project, letting
him refactor code, address TODOs and FIXMEs, remove dead code, and more!
</li>
<li>
When Cline runs commands, you can now type directly in the terminal (+ support for Python
environments)
</li>
</ul>*/}
<div style={hrStyle} />
<p style={linkContainerStyle}>
Join us on{" "}
<VSCodeLink style={linkStyle} href="https://x.com/cline">
X,
</VSCodeLink>{" "}
<VSCodeLink style={linkStyle} href="https://discord.gg/cline">
discord,
</VSCodeLink>{" "}
or{" "}
<VSCodeLink style={linkStyle} href="https://www.reddit.com/r/cline/">
r/cline
</VSCodeLink>
for more updates!
</p>
</div>
)
}
</announcement-component>
- 3.13
<changeset>
Minor Changes
2964388: Added copy button to MermaidBlock component
75143a7: Add the ability to fetch from global cline rules files
Patch Changes
a0252e7: convert inline style to tailwind css of file SettingsView.tsx
ab59bd9: Add stream options back to xai provider
7276f50: Icons to indicate an action is occuring outside of the users workspace
0b19ba6: update to NEW model
</changeset>
<changelog>
## [3.13.0]
- Add Cline rules popover under the chat field, allowing you to easily add, enable & disable workspace level or global rule files
- Add new slash command menu letting you type “/“ to do quick actions like creating new tasks
- Add ability to edit past messages, with options to restore your workspace back to that point
- Allow sending a message when selecting an option provided by the question or plan tool
- Add command to jump to Cline's chat input
- Add support for OpenAI o3 & 4o-mini (Thanks @PeterDaveHello and @arafatkatze!)
- Add baseURL option for Google Gemini provider (Thanks @owengo and @olivierhub!)
- Add support for Azure's DeepSeek model. (Thanks @yt3trees!)
- Add ability for models that support it to receive image responses from MCP servers (Thanks @rikaaa0928!)
- Improve search and replace diff editing by making it more flexible with models that fail to follow structured output instructions. (Thanks @chi-cat!)
- Add detection of Ctrl+C termination in terminal, improving output reading issues
- Fix issue where some commands with large output would cause UI to freeze
- Fix token usage tracking issues with vertex provider (Thanks @mzsima!)
- Fix issue with xAI reasoning content not being parsed (Thanks @mrubens!)
</changelog>
<announcement-component>
const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
const minorVersion = version.split(".").slice(0, 2).join(".") // 2.0.0 -> 2.0
return (
<div style={containerStyle}>
<VSCodeButton appearance="icon" onClick={hideAnnouncement} style={closeIconStyle}>
<span className="codicon codicon-close"></span>
</VSCodeButton>
<h3 style={h3TitleStyle}>
🎉{" "}New in v{minorVersion}
</h3>
<ul style={ulStyle}>
<li>
<b>Global Cline Rules:</b> store multiple rules files in Documents/Cline/Rules to share between projects.
</li>
<li>
<b>Cline Rules Popup:</b> New button in the chat area to view workspace and global cline rules files to plug
and play specific rules for the task
</li>
<li>
<b>Slash Commands:</b> Type <code>/</code> in chat to see the list of quick actions, like starting a new task
(more coming soon!)
</li>
<li>
<b>Edit Messages:</b> You can now edit a message you sent previously by clicking on it. Optionally restore
your project when the message was sent!
</li>
</ul>
<h4 style={{ margin: "5px 0 5px" }}>Previous Updates:</h4>
<ul style={ulStyle}>
<li>
<b>Model Favorites:</b> You can now mark your favorite models when using Cline & OpenRouter providers for
quick access!
</li>
<li>
<b>Faster Diff Editing:</b> Improved animation performance for large files, plus a new indicator in chat
showing the number of edits Cline makes.
</li>
<li>
<b>New Auto-Approve Options:</b> Turn off Cline's ability to read and edit files outside your workspace.
</li>
</ul>
{/*
// Leave this here for an example of how to structure the announcement
<ul style={{ margin: "0 0 8px", paddingLeft: "12px" }}>
<li>
OpenRouter now supports prompt caching! They also have much higher rate limits than other providers,
so I recommend trying them out.
<br />
{!apiConfiguration?.openRouterApiKey && (
<VSCodeButtonLink
href={getOpenRouterAuthUrl(vscodeUriScheme)}
style={{
transform: "scale(0.85)",
transformOrigin: "left center",
margin: "4px -30px 2px 0",
}}>
Get OpenRouter API Key
</VSCodeButtonLink>
)}
{apiConfiguration?.openRouterApiKey && apiConfiguration?.apiProvider !== "openrouter" && (
<VSCodeButton
onClick={() => {
vscode.postMessage({
type: "apiConfiguration",
apiConfiguration: { ...apiConfiguration, apiProvider: "openrouter" },
})
}}
style={{
transform: "scale(0.85)",
transformOrigin: "left center",
margin: "4px -30px 2px 0",
}}>
Switch to OpenRouter
</VSCodeButton>
)}
</li>
<li>
<b>Edit Cline's changes before accepting!</b> When he creates or edits a file, you can modify his
changes directly in the right side of the diff view (+ hover over the 'Revert Block' arrow button in
the center to undo "<code>{"// rest of code here"}</code>" shenanigans)
</li>
<li>
New <code>search_files</code> tool that lets Cline perform regex searches in your project, letting
him refactor code, address TODOs and FIXMEs, remove dead code, and more!
</li>
<li>
When Cline runs commands, you can now type directly in the terminal (+ support for Python
environments)
</li>
</ul>*/}
<div style={hrStyle} />
<p style={linkContainerStyle}>
Join us on{" "}
<VSCodeLink style={linkStyle} href="https://x.com/cline">
X,
</VSCodeLink>{" "}
<VSCodeLink style={linkStyle} href="https://discord.gg/cline">
discord,
</VSCodeLink>{" "}
or{" "}
<VSCodeLink style={linkStyle} href="https://www.reddit.com/r/cline/">
r/cline
</VSCodeLink>
for more updates!
</p>
</div>
)
}
</announcement-component>
We have a changeset PR that automatically generated as new unreleased PRs are merged into main, the PR is always called "Changeset version bump" and the author is github-actions.
The Changeset PR description looks something like this:
<changeset-pr-description>
This PR was opened by the [Changesets release](https://github.com/changesets/action) GitHub action. When you're ready to do a release, you can merge this and publish to npm yourself or [setup this action to publish automatically](https://github.com/changesets/action#with-publishing). If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated.
# Releases
## claude-dev@3.16.0
### Minor Changes
- c6e8b04: Recent task list is now collapsible, allowing users to hide their recent tasks (e.g. when sharing their screen).
- aabe4ae: Add detection for new users to display special components
- 6c18d51: adds global endpoint for vertex ai users
- 080ed7c: Add Tailwind CSS IntelliSense to the the recommended extensions list
- 5147e28: new workflow feature
### Patch Changes
- c0b3c69: fix eternal loading states when the last message is a checkpoint
- 570ece3: selectImages protos migration
- 8d8452e: askResponse protobus migration
- cd1ff2a: Finishing the migration of Vscode Advanced settings to Settings Webview
</changeset-pr-description>
The changeset pr is ALWAYS on the following branch: `changeset-release/main`.
I have the `gh` command line tool set up and authenticated, so you have everything you need.
The first step is to get the full diff from the changeset PR to look at the changes that were automatically made to the `CHANGELOG.md` file. By default it will automatically add a new section to the changelog.md file with the new version. The problem with the automatically generated section is that it just takes the text that the developers threw into their changeset files for each corresponding PR, and they can be pretty vague and bad. Additionally there's some stuff that is totally irrelevant for the end user, like minor refactoring changes. So I manually typically go in and update this section to be a proper changelog that will show up in our patchnotes. You can look at how the rest of the file is done because those are all good examples of us updating this to use good language for the end user. We usually put new features up top (and the most exciting flagship features at the very top), and then bug fixes/improvements at the bottom. Having some basic organization to the ordering of the bullet points by content is nice. But use common sense.
To handle this process effectively, do the following:
For each of the automatically generated bullet points in the Changelog.md, you should
1. Take the commit hash at the start of the bullet point, and use the `gh` command line tool find the PR that it was associated with.
2. Use the `gh` command to get the PR title/description/discussion to understand the context surrounding the PR.
3. Use the `gh` command line tool to get the full PR diff to fully understand the changes made in the code.
4. Synthesize that knowledge to determine (a) whether or not this change is relevant to end users and (b) what the text & ordering of the line should be.
5. Update the `CHANGELOG.md` accordingly
Do this for every single item in the list from the autogenerated bullet points. We want to be diligent and have a full understanding of every feature so we can make the best changelog ever!
Here are some principles for good changelogs from keepchangelog.com, a handy guide:
<keepachangelog-pinciples-for-good-changelogs>
### Guiding Principles
- Changelogs are for humans, not machines.
- There should be an entry for every single version.
- The same types of changes should be grouped.
- The latest version comes first.
### Bullet points in the changelog should follow these principles:
- Types of changes
- Added for new features.
- Changed for changes in existing functionality.
- Deprecated for soon-to-be removed features.
- Removed for now removed features.
- Fixed for any bug fixes.
- Security in case of vulnerabilities.
</keepachangelog-pinciples-for-good-changelogs>
Lastly, when developers make a PR, they typically make a changeset. And they have 3 options when making the changeset:
1. Patch
2. Minor
3. Major
Sometimes they label something as minor when really it should just be a patch. Or vice versa. Because of this, the automatic version bump may be incorrect. So when starting out this workflow, you should use the <ask_followup_question> tool to confirm with me whether or not this should be a patch bump (show the old version number and what the proposed new version number would be) or a minor bump. Part of the release process is making sure the version in package.json that is automatically changed actually corresponds with what we decided the bump should actually be based on the features. ALL these modifications happen in the `changeset-release/main` branch btw.
<important_note>
Before doing any of this, make sure you check out the `changeset-release/main` and pull the most recent up to date changes. Then perform all this work in that branch.
New announcement banners should ONLY be made for minor version bumps or higher. That's another reason why double checking if the changelog warrants the bump is important.
Also, SUPER important: For any external contributors that aren't part of the cline github organization, we always want to add a (Thanks @username!) at the end of the changelog to attribute them properly. We're an open source project and it's ethical to do this.
</important_note>
Once the changelog looks good, and the version number looks good, we gotta double check that the version number in the changelog has the brackets around it. And as a final step, double check the package.json version number matches the latest number in the changelog. And as the ultimate final step we run `npm run install:all` to make sure the package version number permiates through the lock file.
<detailed_sequence_of_steps>
# Cline Release Process - Detailed Sequence of Steps
## Before Starting
1. First, examine the changeset PR without checking it out:
```bash
gh pr view changeset-release/main
```
2. View the PR diff to see the auto-generated CHANGELOG.md changes:
```bash
gh pr diff changeset-release/main > changeset-diff.txt
cat changeset-diff.txt | grep -A 50 "CHANGELOG.md"
```
## Initial Setup
3. Once you're ready to start, checkout and update the changeset release branch:
```bash
git checkout changeset-release/main
git pull origin changeset-release/main
```
## Analyzing Each Change
4. For each commit hash in the auto-generated changelog entries:
a. Find the PR number associated with a commit hash:
```bash
gh pr list --search "<commit-hash>" --state merged
```
b. Get PR details for better context:
```bash
gh pr view <PR-number>
```
c. Check if the contributor is external to determine if attribution is needed:
```bash
# Extract username from PR
USERNAME=$(gh pr view <PR-number> --json author --jq .author.login)
# Check if user is a member of the Cline organization
# this command is a bit finnicky, but it 100% works.
# if you see a `Error executing command: The command ran successfully, but we couldn't capture its output. Please proceed accordingly.` error, just retry it until you actually get the output
# don't make any assumptions, just retry the command to actually get the output and determine if they're external or not.
# no output means they are an external contributor, otherwise if there is output they are an internal contributor (part of our github org)
gh api "orgs/cline/members" --jq "map(.login)" | grep -i "pashpashpash"
```
d. View the full PR diff to understand code changes:
```bash
gh pr diff <PR-number> > pr-diff-<PR-number>.txt
cat pr-diff-<PR-number>.txt
```
## Updating the Changelog
5. Based on PR analysis, update the CHANGELOG.md with user-friendly descriptions:
- Use the `<replace_in_file>` tool to edit the CHANGELOG.md file
- Group by feature type (Added, Changed, Fixed)
- Put most exciting features at the top
- Move bug fixes and small improvements to the bottom
- Use clear, end-user focused language
- For external contributors, add attribution at the end of the relevant entry: `(Thanks @username!)`
## Version Number Verification
6. Confirm the version bump is appropriate:
- Check package.json to verify the auto-generated version number:
```bash
cat package.json | grep "\"version\""
```
- If the feature set doesn't warrant a minor bump, use the `<replace_in_file>` tool to modify package.json
7. Ensure the version in CHANGELOG.md has brackets around it:
```
## [3.16.0]
```
## Creating the Announcement (for minor/major versions only)
8. If this is a minor version bump, create/update the announcement component:
- Use the `<replace_in_file>` tool to edit the src/views/components/announcement.tsx file
- Update the highlights based on key features
- Move previous version highlights to the "Previous Updates" section
- Use the previous announcement components as reference for structure
## Finalizing the Release
9. Update dependencies with the new version number:
```bash
npm run install:all
```
10. Commit your changes:
```bash
git add CHANGELOG.md package.json package-lock.json src/views/components/announcement.tsx
git commit -m "Update CHANGELOG.md and announcement for version 3.16.0"
```
11. Push your changes to the changeset branch:
```bash
git push origin changeset-release/main
```
12. Check that your changes pushed successfully:
```bash
git status
```
</detailed_sequence_of_steps>
+3 -10
View File
@@ -89,16 +89,9 @@ On the main branch, create a commit that updates:
2. **package.json** - Update the version field to the new version
3. **Delete changesets** for the commits being included in the hotfix. This prevents the changeset bot from including duplicate entries in the next regular release.
3. No changelog-entry file cleanup is needed. Contributors do not create changelog-entry files in this repo.
Find and delete the changeset files associated with the selected commits:
```bash
ls .changeset/
```
Each changeset file in `.changeset/` corresponds to a PR. Read them to identify which ones belong to the commits you're hotfixing, then delete those files.
**Skip running `npm run install:all`** - the automation handles outdated lockfiles.
**Skip running `npm run install:all`** - release automation handles lockfile consistency as needed.
Commit with message format: `v{VERSION} Release Notes (hotfix)`
@@ -107,7 +100,7 @@ In the commit body, mention:
- List the cherry-picked commits that will be included
```bash
git add CHANGELOG.md package.json .changeset/
git add CHANGELOG.md package.json
git commit -m "v3.40.1 Release Notes (hotfix)
Hotfix release including:
-2
View File
@@ -347,8 +347,6 @@ A few notes:
So until the settings page is update, and this is added to settings in a way that's clean and doesn't confuse new users, I don't think we can merge this. Please bear with us.
</request_changes_comment>
<request_changes_comment>
Also, don't forget to add a changeset since this fixes a user-facing bug.
The architectural change is solid - moving the focus logic to the command handlers makes sense. Just don't want to introduce subtle timing issues by removing those timeouts.
</request_changes_comment>
</example_comments_that_i_have_written_before>
+40 -208
View File
@@ -1,232 +1,64 @@
# Release
Prepare and publish a release from the open changeset PR.
Prepare and publish a release directly from `main`.
## Overview
This workflow helps you:
1. Find and checkout the open changeset PR
2. Clean up the changelog (fix version format, wordsmith entries)
3. Push changes back to the PR branch
4. Merge with proper commit message format
5. Tag and push the release (after verifying the commit)
6. Trigger the publish workflow
7. Update GitHub release notes
8. Provide final summary with Slack announcement
1. Select/confirm the target version
2. Curate `CHANGELOG.md` entries manually for end users
3. Ensure `package.json` version matches the changelog
4. Create and push a release commit + tag
5. Trigger publish workflow
6. Update GitHub release notes and share a summary
## Step 1: Find the Changeset PR
## Process
Look for the open changeset PR:
```bash
gh pr list --search "Changeset version bump" --state open --json number,title,headRefName,url
```
If no PR is found, inform the user there's no changeset PR ready. They may need to:
- Merge PRs with changesets to main first
- Manually trigger the Changeset Converter workflow at: https://github.com/cline/cline/actions/workflows/changeset-converter.yml
## Step 2: Gather PR Information
Get the PR details:
```bash
PR_NUMBER=<number from step 1>
gh pr view $PR_NUMBER --json body,files,headRefName
```
Checkout the PR branch:
```bash
git fetch origin changeset-release/main
git checkout changeset-release/main
```
If the branch has diverged from remote, reset to the remote version:
```bash
git reset --hard origin/changeset-release/main
```
## Step 3: Analyze the Changes
Read the current CHANGELOG.md to see what the automation generated:
```bash
head -50 CHANGELOG.md
```
Get the version from package.json:
```bash
cat package.json | grep '"version"'
```
**Present to the user:**
- The version number that will be released
- The raw changelog entries from the changeset PR
- Whether this is a patch, minor, or major release
## Step 4: Clean Up the Changelog
The changelog needs these fixes:
1. **Add brackets to version number**: Change `## 3.44.1` to `## [3.44.1]`
2. **No category headers**: Don't use `### Added`, `### Fixed`, etc. Just a flat list of bullet points.
3. **Order entries from most important to least important**:
- Lead with major new features or significant fixes users care about
- End with minor fixes or internal changes
4. **Write user-friendly descriptions**:
- This is for end users, not developers—explain what changed in plain language
- Remove commit hashes from the beginning of lines (the automation adds these)
- Look at the actual commit diffs (`git show <hash>`) and PRs to understand what changed
- Write colorful descriptions that explain the value and impact, not just technical details
- Consolidate related changes into single entries when appropriate
**Ask the user** to review the proposed changelog changes before applying them. Show them:
- Current (raw) changelog section
- Proposed (cleaned) changelog section
Once approved, apply the changes to CHANGELOG.md.
## Step 5: Commit and Push Changes
After making changelog edits:
```bash
git add CHANGELOG.md
git commit -m "Clean up changelog formatting"
git push origin changeset-release/main
```
## Step 6: Merge the PR
**Ask the user to confirm** they're ready to merge.
Merge the PR with the proper commit message format:
```bash
VERSION=<version from package.json>
gh pr merge $PR_NUMBER --squash --subject "v${VERSION} Release Notes" --body ""
```
**If merge is blocked by branch protection:**
- Users with admin privileges can add the `--admin` flag to bypass
- Users without admin privileges need to get the PR approved through normal review first before merging
## Step 7: Tag the Release
After the merge completes, checkout main and pull:
### 1) Sync and determine version
```bash
git checkout main
git pull origin main
cat package.json | grep '"version"'
```
**IMPORTANT: Verify the latest commit is the release commit before tagging:**
Confirm the release version with the maintainer (patch/minor/major).
### 2) Curate changelog and version
- Edit `CHANGELOG.md` for the target version using human-friendly release notes.
- Ensure version headers use bracket format, e.g. `## [3.66.1]`.
- Update `package.json` version to the same value.
### 3) Commit and tag
```bash
git log -1 --oneline
git add CHANGELOG.md package.json package-lock.json
git commit -m "v<version> Release Notes"
git push origin main
git tag v<version>
git push origin v<version>
```
Confirm the commit message matches `v{VERSION} Release Notes` (e.g., `v3.44.1 Release Notes`). Do NOT blindly tag HEAD without verification.
### 4) Trigger publish workflow
Once verified, tag and push:
Tell the maintainer to run:
https://github.com/cline/cline/actions/workflows/publish.yml
Use `v<version>` as the release tag.
### 5) Update GitHub release notes
After publish completes:
```bash
VERSION=<version>
git tag v${VERSION}
git push origin v${VERSION}
gh release view v<version> --json body --jq '.body'
gh release edit v<version> --notes "<final curated release notes>"
```
## Step 8: Trigger Publish Workflow
### 6) Final summary
**Copy the tag to clipboard** so the user can easily paste it into the GitHub Actions workflow:
```bash
echo -n "v{VERSION}" | pbcopy
```
**Tell the user to trigger the publish workflow:**
1. Go to: https://github.com/cline/cline/actions/workflows/publish.yml
2. Select **"release"** for release-type
3. Paste **`v{VERSION}`** as the tag (already in clipboard)
**Wait for the user** to confirm the publish workflow has completed before proceeding.
## Step 9: Update GitHub Release Notes
Once the user confirms the publish workflow is done, fetch the auto-generated release content:
```bash
VERSION=<version>
gh release view v${VERSION} --json body --jq '.body'
```
The auto-generated release has:
- `## What's Changed` - PR list (we'll replace this with our changelog)
- `## New Contributors` - First-time contributors (keep this if present)
- `**Full Changelog**` - Comparison link (keep this)
Build the new release body:
1. Start with `## What's Changed` header
2. Add our changelog content (from CHANGELOG.md for this version)
3. Keep the `## New Contributors` section if it exists
4. Keep the `**Full Changelog**` link
Update the release:
```bash
gh release edit v${VERSION} --notes "<new body content>"
```
Verify the release was updated:
```bash
gh release view v${VERSION}
```
## Step 10: Final Summary
**Copy a Slack announcement message to clipboard** (include the full changelog, not just highlights):
```bash
echo "VS Code v{VERSION} Released
- Changelog entry 1
- Changelog entry 2
- Changelog entry 3" | pbcopy
```
**Present a final summary:**
- Version released: v{VERSION}
- PR merged: #{PR_NUMBER}
- Tag pushed: v{VERSION}
- Release: https://github.com/cline/cline/releases/tag/v{VERSION}
- Slack message copied to clipboard
**Final reminder:**
Post the Slack message to announce the release
## Handling Edge Cases
### No changesets found
If the changeset PR body shows no changes, inform the user they need to merge PRs with changesets first.
### Merge conflicts
If there are conflicts on the changeset branch, help the user resolve them:
```bash
git fetch origin main
git rebase origin/main
# resolve conflicts
git push origin changeset-release/main --force-with-lease
```
### User wants to add more changes
If the user wants to include additional PRs before releasing:
1. Ask them to merge those PRs to main first
2. The changeset automation will update the PR automatically
3. Re-run this workflow after the PR is updated
Provide:
- Released version/tag
- Link to release page
- Summary of top end-user changes
-1
View File
@@ -60,7 +60,6 @@ We're not looking for exhaustive documentation - just evidence that you've thoug
- [ ] Changes are limited to a single feature, bugfix or chore (split larger changes into separate PRs)
- [ ] Tests are passing (`npm test`) and code is formatted and linted (`npm run format && npm run lint`)
- [ ] I have created a changeset using `npm run changeset` (required for user-facing changes)
- [ ] I have reviewed [contributor guidelines](https://github.com/cline/cline/blob/main/CONTRIBUTING.md)
### Screenshots
@@ -1,79 +0,0 @@
"""
This script updates a specific version's release notes section in CHANGELOG.md with new content
or reformats existing content.
The script:
1. Takes a version number, changelog path, and optionally new content as input from environment variables
2. Finds the section in the changelog for the specified version
3. Either:
a) Replaces the content with new content if provided, or
b) Reformats existing content by:
- Removing the first two lines of the changeset format
- Ensuring version numbers are wrapped in square brackets
4. Writes the updated changelog back to the file
Environment Variables:
CHANGELOG_PATH: Path to the changelog file (defaults to 'CHANGELOG.md')
VERSION: The version number to update/format
PREV_VERSION: The previous version number (used to locate section boundaries)
NEW_CONTENT: Optional new content to insert for this version
"""
#!/usr/bin/env python3
import os
CHANGELOG_PATH = os.environ.get("CHANGELOG_PATH", "CHANGELOG.md")
VERSION = os.environ['VERSION']
PREV_VERSION = os.environ.get("PREV_VERSION", "")
NEW_CONTENT = os.environ.get("NEW_CONTENT", "")
def overwrite_changelog_section(changelog_text: str, new_content: str):
# Find the section for the specified version
version_pattern = f"## {VERSION}\n"
unformmatted_prev_version_pattern = f"## {PREV_VERSION}\n"
prev_version_pattern = f"## [{PREV_VERSION}]\n"
print(f"latest version: {VERSION}")
print(f"prev_version: {PREV_VERSION}")
notes_start_index = changelog_text.find(version_pattern) + len(version_pattern)
notes_end_index = changelog_text.find(prev_version_pattern, notes_start_index) if PREV_VERSION and (prev_version_pattern in changelog_text or unformmatted_prev_version_pattern in changelog_text) else len(changelog_text)
if new_content:
return changelog_text[:notes_start_index] + f"{new_content}\n" + changelog_text[notes_end_index:]
else:
changeset_lines = changelog_text[notes_start_index:notes_end_index].split("\n")
filtered_lines = []
for line in changeset_lines:
# If the previous line is a changeset format
if len(filtered_lines) > 1 and filtered_lines[-1].startswith("### "):
# Remove the last two lines from the filted_lines
filtered_lines.pop()
filtered_lines.pop()
else:
filtered_lines.append(line.strip())
# Prepend a new line to the first line of filtered_lines
if filtered_lines:
filtered_lines[0] = "\n" + filtered_lines[0]
# Print filted_lines wiht a "\n" at the end of each line
for line in filtered_lines:
print(line.strip())
parsed_lines = "\n".join(line for line in filtered_lines)
updated_changelog = changelog_text[:notes_start_index] + parsed_lines + changelog_text[notes_end_index:]
return updated_changelog
with open(CHANGELOG_PATH, 'r') as f:
changelog_content = f.read()
new_changelog = overwrite_changelog_section(changelog_content, NEW_CONTENT)
# print("----------------------------------------------------------------------------------")
# print(new_changelog)
# print("----------------------------------------------------------------------------------")
# Write back to CHANGELOG.md
with open(CHANGELOG_PATH, 'w') as f:
f.write(new_changelog)
print(f"{CHANGELOG_PATH} updated successfully!")
-113
View File
@@ -1,113 +0,0 @@
name: Changeset Converter
run-name: Changeset Conversion
on:
workflow_dispatch:
pull_request:
types: [closed]
env:
REPO_PATH: ${{ github.repository }}
GIT_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || 'main' }}
NODE_VERSION: 20.18.1
jobs:
# Job 1: Create version bump PR when changesets are merged to main
changeset-pr-version-bump:
if: |
github.event_name == 'workflow_dispatch' ||
(
github.event_name == 'pull_request' &&
github.event.pull_request.merged == true &&
github.event.pull_request.base.ref == 'main' &&
github.actor != 'github-actions'
)
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Check user for team affiliation
id: team_check
if: github.event_name == 'workflow_dispatch'
uses: morfien101/actions-authorized-user@4a3cfbf0bcb3cafe4a71710a278920c5d94bb38b
with:
username: ${{ github.actor }}
org: ${{ github.repository_owner }}
team: "deployer"
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Check if user is authorized
if: github.event_name == 'workflow_dispatch'
run: |
if [ "${{ steps.team_check.outputs.authorized }}" != "true" ]; then
echo "User is not authorized to run this workflow."
exit 1
fi
- name: Git Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ env.GIT_REF }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "npm"
- name: Install Dependencies
run: npm ci
# Check if there are any new changesets to process
- name: Check for changesets
id: check-changesets
run: |
NEW_CHANGESETS=$(find .changeset -name "*.md" ! -name "README.md" | wc -l | tr -d ' ')
echo "Changesets diff with previous version: $NEW_CHANGESETS"
echo "new_changesets=$NEW_CHANGESETS" >> $GITHUB_OUTPUT
# Create version bump PR using changesets/action if there are new changesets
- name: Create Changeset Pull Request
if: steps.check-changesets.outputs.new_changesets != '0'
uses: changesets/action@v1
with:
commit: "changeset version bump"
title: "Changeset version bump"
version: npm run version-packages # This performs the changeset version bump
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Get current and previous versions to edit changelog entry
- name: Get version
id: get_version
run: |
VERSION=$(git show HEAD:package.json | jq -r '.version')
echo "version=$VERSION" >> $GITHUB_OUTPUT
PREV_VERSION=$(git show origin/main:package.json | jq -r '.version')
echo "prev_version=$PREV_VERSION" >> $GITHUB_OUTPUT
echo "version=$VERSION"
echo "prev_version=$PREV_VERSION"
# Update CHANGELOG.md with proper format
- name: Update Changelog Format
env:
VERSION: ${{ steps.get_version.outputs.version }}
PREV_VERSION: ${{ steps.get_version.outputs.prev_version }}
run: python .github/scripts/overwrite_changeset_changelog.py
# Commit and push changelog updates
- name: Push Changelog updates to Pull Request
run: |
git config user.name "github-actions"
git config user.email github-actions@github.com
echo "Running git add and commit..."
git add CHANGELOG.md
git commit -m "Updating CHANGELOG.md format"
git status
echo "--------------------------------------------------------------------------------"
echo "Pushing to remote..."
echo "--------------------------------------------------------------------------------"
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
git push origin $CURRENT_BRANCH
+16 -1
View File
@@ -55,7 +55,22 @@ jobs:
CLINE_API_KEY: ${{ secrets.CLINE_API_KEY }}
run: |
cline auth -p cline -k "$CLINE_API_KEY" -m "anthropic/claude-sonnet-4.5"
npx tsx evals/smoke-tests/run-smoke-tests.ts --trials 1 --parallel
max_attempts=3
for attempt in $(seq 1 $max_attempts); do
echo "::group::Attempt $attempt of $max_attempts"
if npx tsx evals/smoke-tests/run-smoke-tests.ts --trials 1 --parallel; then
echo "::endgroup::"
echo "Smoke tests passed on attempt $attempt"
exit 0
fi
echo "::endgroup::"
if [ $attempt -lt $max_attempts ]; then
echo "::warning::Smoke tests failed on attempt $attempt, retrying..."
sleep 10
fi
done
echo "::error::Smoke tests failed after $max_attempts attempts"
exit 1
- name: Generate summary
if: always()
+3 -1
View File
@@ -36,7 +36,9 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "lts/*"
# Keep publish environment aligned with test workflow/tooling lockfile expectations.
# Newer LTS (Node 24 / npm 11) can make `npm list` fail with ELSPROBLEMS during vsce packaging.
node-version: 22
- name: Install root dependencies
run: npm ci --include=optional
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "lts/*"
node-version: 22
- name: Install root dependencies
run: npm install --include=optional
+2 -1
View File
@@ -16,7 +16,8 @@
"saoudrizwan.claude-dev", // Avoid conflicts with installed Cline
"--disable-extension",
"saoudrizwan.cline-nightly", // Avoid conflicts with installed Cline Nightly
"${workspaceFolder}"
"${workspaceFolder}",
"--disable-extensions"
],
"outFiles": [
"${workspaceFolder}/dist/**/*.js"
-2
View File
@@ -35,11 +35,9 @@ cli/**
eslint-rules/
old_docs/
evals/
.changie.yaml
.codespellrc
.mocharc.json
buf.yaml
.changeset/
.clinerules/
# Ignore all webview-ui files except the build directory (https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/frameworks/hello-world-react-cra/.vscodeignore)
+30
View File
@@ -1,5 +1,35 @@
# Changelog
## [3.67.0]
### Added
- Add support for skills and optional modelId in subagent configuration
- Add AgentConfigLoader for file-based agent configs
- Add Responses API support for OpenAI native provider
- Preconnect websocket to reduce response latency
- Fetch featured models from backend with local fallback
- Add /q command to quit CLI
- Add MCP enterprise configuration details
- Pull Cline's recommended models from internal endpoint
- Add dynamic flag to adjust banner cache duration
### Fixed
- Fix reasoning delta crash on usage-only stream chunks
- Fix OpenAI tool ID transformation restricted to native provider only
- Fix auth check for ACP mode
- Fix CLI yolo mode to not persist yolo setting to disk
- Fix inline focus-chain slider within its feature row
- Fix Gemini 3.1 Pro compatibility
- Fix Cline auth with ACP flag
### Changed
- Move PR skill to .agents/skills
- SambaNova provider: update models list
- Remove changeset-converter GitHub Action and npm run changeset
## [3.66.0]
### Added
+6 -25
View File
@@ -57,25 +57,11 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
### Creating a Pull Request
1. Before creating a PR, generate a changeset entry:
```bash
npm run changeset
```
This will prompt you for:
- Type of change (major, minor, patch)
- `major` → breaking changes (1.0.0 → 2.0.0)
- `minor` → new features (1.0.0 → 1.1.0)
- `patch` → bug fixes (1.0.0 → 1.0.1)
- Description of your changes
1. Commit your changes.
2. Commit your changes and the generated `.changeset` file
3. Push your branch and create a PR on GitHub. Our CI will:
2. Push your branch and create a PR on GitHub. Our CI will:
- Run tests and checks
- Changesetbot will create a comment showing the version impact
- When merged to main, changesetbot will create a Version Packages PR
- When the Version Packages PR is merged, a new release will be published
4. Testing
3. Testing
- Run `npm run test` to run tests locally.
- Before submitting PR, run `npm run format:fix` to format your code
@@ -192,15 +178,10 @@ Anyone can contribute code to Cline, but we ask that you follow these guidelines
- Temporary workspaces with test fixtures
- Video recording for failed tests
4. **Version Management with Changesets**
4. **Versioning & Changelog Notes**
- Create a changeset for any user-facing changes using `npm run changeset`
- Choose the appropriate version bump:
- `major` for breaking changes (1.0.0 → 2.0.0)
- `minor` for new features (1.0.0 → 1.1.0)
- `patch` for bug fixes (1.0.0 → 1.0.1)
- Write clear, descriptive changeset messages that explain the impact
- Documentation-only changes don't require changesets
- Contributors do not need to create changelog-entry files as part of PRs.
- Maintainers handle release versioning and changelog curation during the release process.
5. **Commit Guidelines**
+13
View File
@@ -1,5 +1,18 @@
# cline
## 2.4.3
### Added
- Add /q command to quit CLI
- Fetch featured models from backend with local fallback
### Fixed
- Fix auth check for ACP mode
- Fix Cline auth with ACP flag
- Fix yolo mode to not persist yolo setting to disk
## 2.4.2
### Added
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cline",
"version": "2.4.2",
"version": "2.4.3",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"main": "dist/cli.mjs",
"bin": {
+2 -35
View File
@@ -38,7 +38,6 @@ import {
} from "@shared/api"
import type { ClineAsk, ClineMessage as ClineMessageType } from "@shared/ExtensionMessage"
import { CLI_ONLY_COMMANDS, VSCODE_ONLY_COMMANDS } from "@shared/slashCommands"
import { ProviderToApiKeyMap } from "@shared/storage"
import { getProviderModelIdKey } from "@shared/storage/provider-keys"
import { ClineEndpoint } from "@/config.js"
import { Controller } from "@/core/controller"
@@ -58,6 +57,7 @@ import { openExternal } from "@/utils/env"
import { ACPDiffViewProvider } from "../acp/ACPDiffViewProvider.js"
import { ACPHostBridgeClientProvider } from "../acp/ACPHostBridgeClientProvider.js"
import { AcpTerminalManager } from "../acp/AcpTerminalManager.js"
import { isAuthConfigured } from "../index.js"
import { fetchOpenRouterModels, usesOpenRouterModels } from "../utils/openrouter-models"
import { CliContextResult, initializeCliContext } from "../vscode-context.js"
import { ClineSessionEmitter } from "./ClineSessionEmitter.js"
@@ -265,7 +265,7 @@ export class ClineAgent implements acp.Agent {
*/
async newSession(params: acp.NewSessionRequest): Promise<acp.NewSessionResponse> {
// Check if authentication is required
const isAuthenticated = await this.isAuthConfigured()
const isAuthenticated = await isAuthConfigured()
if (!isAuthenticated) {
throw RequestError.authRequired()
}
@@ -1146,39 +1146,6 @@ export class ClineAgent implements acp.Agent {
}
}
/**
* Check if the user has authentication configured.
* Returns true if they have either:
* - Cline provider with stored auth data
* - OpenAI Codex provider with OAuth credentials
* - BYO provider with an API key configured
*/
private async isAuthConfigured(): Promise<boolean> {
const stateManager = StateManager.get()
const mode = stateManager.getGlobalSettingsKey("mode") as string
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
const currentProvider = (stateManager.getGlobalSettingsKey(providerKey) as string) || "cline"
if (currentProvider === "cline") {
// For Cline provider, check if we have stored auth data
return Boolean(stateManager.getSecretKey("clineApiKey") || stateManager.getSecretKey("clineAccountId"))
}
// For OpenAI Codex provider, check OAuth credentials
if (currentProvider === "openai-codex") {
return await openAiCodexOAuthManager.isAuthenticated()
}
// For BYO providers, check if the API key is configured
const keyField = ProviderToApiKeyMap[currentProvider as keyof typeof ProviderToApiKeyMap]
if (!keyField) {
return false
}
const fields = Array.isArray(keyField) ? keyField : [keyField]
return fields.some((key) => stateManager.getSecretKey(key))
}
/**
* Handle OpenAI Codex OAuth authentication flow.
*
+6 -4
View File
@@ -15,6 +15,7 @@ import { StringRequest } from "@/shared/proto/cline/common"
import { openExternal } from "@/utils/env"
import { COLORS } from "../constants/colors"
import { useStdinContext } from "../context/StdinContext"
import { useClineFeaturedModels } from "../hooks/useClineFeaturedModels"
import { useOcaAuth } from "../hooks/useOcaAuth"
import { useScrollableList } from "../hooks/useScrollableList"
import { type DetectedSources, detectImportSources, type ImportSource } from "../utils/import-configs"
@@ -172,6 +173,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
const [providerSearch, setProviderSearch] = useState("")
const [providerIndex, setProviderIndex] = useState(0)
const [clineModelIndex, setClineModelIndex] = useState(0)
const featuredModels = useClineFeaturedModels()
const [importSources, setImportSources] = useState<DetectedSources>({ codex: false, opencode: false })
const [importSource, setImportSource] = useState<ImportSource | null>(null)
const [bedrockConfig, setBedrockConfig] = useState<BedrockConfig | null>(null)
@@ -767,7 +769,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
<Box flexDirection="column">
<Text color="white">Choose a model</Text>
<Text> </Text>
<FeaturedModelPicker selectedIndex={clineModelIndex} />
<FeaturedModelPicker featuredModels={featuredModels} selectedIndex={clineModelIndex} />
</Box>
)
}
@@ -869,17 +871,17 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
setProviderSearch((prev) => prev + input)
}
} else if (step === "cline_model") {
const maxIndex = getFeaturedModelMaxIndex()
const maxIndex = getFeaturedModelMaxIndex(featuredModels)
if (key.upArrow) {
setClineModelIndex((prev) => (prev > 0 ? prev - 1 : maxIndex))
} else if (key.downArrow) {
setClineModelIndex((prev) => (prev < maxIndex ? prev + 1 : 0))
} else if (key.return) {
if (isBrowseAllSelected(clineModelIndex)) {
if (isBrowseAllSelected(clineModelIndex, featuredModels)) {
setStep("modelid")
} else {
const selectedModel = getFeaturedModelAtIndex(clineModelIndex)
const selectedModel = getFeaturedModelAtIndex(clineModelIndex, featuredModels)
if (selectedModel) {
handleClineModelSelect(selectedModel.id)
}
+45 -46
View File
@@ -106,16 +106,13 @@ import { combineCommandSequences } from "@shared/combineCommandSequences"
import { combineHookSequences } from "@shared/combineHookSequences"
import type { ClineAsk, ClineMessage } from "@shared/ExtensionMessage"
import { getApiMetrics, getLastApiReqTotalTokens } from "@shared/getApiMetrics"
import { EmptyRequest, StringRequest } from "@shared/proto/cline/common"
import type { SlashCommandInfo } from "@shared/proto/cline/slash"
import { CLI_ONLY_COMMANDS } from "@shared/slashCommands"
import { StringRequest } from "@shared/proto/cline/common"
import { getProviderDefaultModelId, getProviderModelIdKey } from "@shared/storage"
import type { Mode } from "@shared/storage/types"
import { execSync } from "child_process"
import { Box, Static, Text, useApp, useInput } from "ink"
// biome-ignore lint/style/useImportType: JSX requires React as a value (jsx: "react" in tsconfig)
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { getAvailableSlashCommands } from "@/core/controller/slash/getAvailableSlashCommands"
import { showTaskWithId } from "@/core/controller/task/showTaskWithId"
import { StateManager } from "@/core/storage/StateManager"
import { telemetryService } from "@/services/telemetry"
@@ -123,6 +120,7 @@ import { Logger } from "@/shared/services/Logger"
import { Session } from "@/shared/services/Session"
import { COLORS } from "../constants/colors"
import { useTaskContext, useTaskState } from "../context/TaskContext"
import { useFilteredSlashCommands } from "../hooks/useFilteredSlashCommands"
import { useHomeEndKeys } from "../hooks/useHomeEndKeys"
import { useIsSpinnerActive } from "../hooks/useStateSubscriber"
import { findWordEnd, findWordStart, useTextInput } from "../hooks/useTextInput"
@@ -137,7 +135,7 @@ import {
} from "../utils/file-search"
import { isMouseEscapeSequence } from "../utils/input"
import { jsonParseSafe, parseImagesFromInput } from "../utils/parser"
import { extractSlashQuery, filterCommands, insertSlashCommand, sortCommandsWorkflowsFirst } from "../utils/slash-commands"
import { insertSlashCommand } from "../utils/slash-commands"
import { waitFor } from "../utils/timeout"
import { isFileEditTool, parseToolFromMessage } from "../utils/tools"
import { shutdownEvent } from "../vscode-shim"
@@ -403,14 +401,17 @@ export const ChatView: React.FC<ChatViewProps> = ({
const PASTE_UPDATE_DEBOUNCE_MS = 50 // Debounce visual updates to avoid flicker
// Slash command state
const [availableCommands, setAvailableCommands] = useState<SlashCommandInfo[]>([])
const [selectedSlashIndex, setSelectedSlashIndex] = useState(0)
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false)
const lastSlashIndexRef = useRef<number>(-1)
// Panel state
const [activePanel, setActivePanel] = useState<
| { type: "settings"; initialMode?: "model-picker" | "featured-models"; initialModelKey?: "actModelId" | "planModelId" }
| {
type: "settings"
initialMode?: "model-picker" | "featured-models"
initialModelKey?: "actModelId" | "planModelId"
}
| { type: "history" }
| { type: "help" }
| { type: "skills" }
@@ -559,22 +560,8 @@ export const ChatView: React.FC<ChatViewProps> = ({
const { prompt, imagePaths } = parseImagesFromInput(textInput)
const mentionInfo = useMemo(() => extractMentionQuery(textInput), [textInput])
const slashInfo = useMemo(() => extractSlashQuery(textInput, cursorPos), [textInput, cursorPos])
const filteredCommands = useMemo(
() => filterCommands(availableCommands, slashInfo.query),
[availableCommands, slashInfo.query],
)
// Reset slash menu dismissed state when a new slash is typed
useEffect(() => {
if (slashInfo.slashIndex !== lastSlashIndexRef.current) {
lastSlashIndexRef.current = slashInfo.slashIndex
setSlashMenuDismissed(false)
setSelectedSlashIndex(0)
}
}, [slashInfo.slashIndex])
const workspacePath = useMemo(() => {
const initialWorkspacePath = useMemo(() => {
try {
const root = ctrl?.getWorkspaceManagerSync?.()?.getPrimaryRoot?.()
if (root?.path) {
@@ -586,6 +573,27 @@ export const ChatView: React.FC<ChatViewProps> = ({
return process.cwd()
}, [ctrl])
// Track the current working directory - updated when change_directory tool is used
const [currentCwd, setCurrentCwd] = useState<string>(initialWorkspacePath)
// Sync currentCwd when initialWorkspacePath changes (e.g. task switch)
useEffect(() => {
setCurrentCwd(initialWorkspacePath)
}, [initialWorkspacePath])
const workspacePath = currentCwd
const { cmds: filteredCommands, allCmds: allCommands, slashInfo } = useFilteredSlashCommands(ctrl, textInput, cursorPos)
// Reset slash menu dismissed state when a new slash is typed
useEffect(() => {
if (slashInfo.slashIndex !== lastSlashIndexRef.current) {
lastSlashIndexRef.current = slashInfo.slashIndex
setSlashMenuDismissed(false)
setSelectedSlashIndex(0)
}
}, [slashInfo.slashIndex])
// Get git branch on mount
useEffect(() => {
setGitBranch(getGitBranch(workspacePath))
@@ -607,28 +615,6 @@ export const ChatView: React.FC<ChatViewProps> = ({
})
}, [taskId, ctrl, onError])
// Load available slash commands on mount
useEffect(() => {
const loadCommands = async () => {
if (!ctrl) return
try {
const response = await getAvailableSlashCommands(ctrl, EmptyRequest.create())
const cliCommands = response.commands.filter((cmd) => cmd.cliCompatible !== false)
// Add CLI-only commands (like /settings) that are handled locally
const cliOnlyCommands: SlashCommandInfo[] = CLI_ONLY_COMMANDS.map((cmd) => ({
name: cmd.name,
description: cmd.description || "",
section: cmd.section || "default",
cliCompatible: true,
}))
setAvailableCommands([...cliOnlyCommands, ...sortCommandsWorkflowsFirst(cliCommands)])
} catch {
// Fallback: commands will be empty, menu won't show
}
}
loadCommands()
}, [ctrl])
// Get history items (limited to MAX_HISTORY_ITEMS, most recent first)
const getHistoryItems = useCallback(() => {
const history = StateManager.get().getGlobalStateKey("taskHistory")
@@ -643,6 +629,19 @@ export const ChatView: React.FC<ChatViewProps> = ({
const messages = taskState.clineMessages || []
// Watch for CWD changes from the change_directory tool.
// Task.changeCwd() updates task.cwd and then calls postStateToWebview(), which triggers
// a TaskContext state update and re-renders ChatView. At that point ctrl.task?.cwd has
// the new value — we just read it directly. This is clean and handles approval/denial
// correctly: if the user rejects the tool, changeCwd() is never called, so task.cwd
// stays unchanged and this effect is a no-op.
useEffect(() => {
const taskCwd = ctrl?.task?.cwd
if (taskCwd && taskCwd !== currentCwd) {
setCurrentCwd(taskCwd)
}
}, [taskState, ctrl?.task?.cwd])
// Refresh git diff stats when messages change (after file edits)
const lastMsg = messages[messages.length - 1]
useEffect(() => {
@@ -1172,7 +1171,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
setSlashMenuDismissed(true)
return
}
if (cmd.name === "exit") {
if (cmd.name === "exit" || cmd.name === "q") {
handleExit()
return
}
@@ -1525,7 +1524,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
<Box>
{inputPrompt && <Text color={borderColor}>{inputPrompt} </Text>}
<HighlightedInput
availableCommands={availableCommands.map((c) => c.name)}
availableCommands={allCommands.map((c) => c.name)}
cursorPos={cursorPos}
text={textInput}
/>
+10 -11
View File
@@ -7,13 +7,14 @@
import { Box, Text } from "ink"
import React from "react"
import { COLORS } from "../constants/colors"
import { type FeaturedModel, getAllFeaturedModels } from "../constants/featured-models"
import type { FeaturedModel } from "../constants/featured-models"
interface FeaturedModelPickerProps {
selectedIndex: number
title?: string
showBrowseAll?: boolean
helpText?: string
featuredModels: FeaturedModel[]
}
export const FeaturedModelPicker: React.FC<FeaturedModelPickerProps> = ({
@@ -21,8 +22,9 @@ export const FeaturedModelPicker: React.FC<FeaturedModelPickerProps> = ({
title,
showBrowseAll = true,
helpText = "Arrows to navigate, Enter to select",
featuredModels,
}) => {
const featuredModels = getAllFeaturedModels()
const models = featuredModels
return (
<Box flexDirection="column">
@@ -35,7 +37,7 @@ export const FeaturedModelPicker: React.FC<FeaturedModelPickerProps> = ({
</Text>
)}
{featuredModels.map((model, i) => {
{models.map((model, i) => {
const isSelected = i === selectedIndex
return (
@@ -64,8 +66,8 @@ export const FeaturedModelPicker: React.FC<FeaturedModelPickerProps> = ({
{showBrowseAll && (
<Box>
<Text color={selectedIndex === featuredModels.length ? COLORS.primaryBlue : "white"}>
{selectedIndex === featuredModels.length ? " " : " "}
<Text color={selectedIndex === models.length ? COLORS.primaryBlue : "white"}>
{selectedIndex === models.length ? " " : " "}
Browse all models...
</Text>
</Box>
@@ -81,24 +83,21 @@ export const FeaturedModelPicker: React.FC<FeaturedModelPickerProps> = ({
* Get the maximum valid index for the featured model picker
* (includes "Browse all" option if showBrowseAll is true)
*/
export function getFeaturedModelMaxIndex(showBrowseAll = true): number {
const featuredModels = getAllFeaturedModels()
export function getFeaturedModelMaxIndex(featuredModels: FeaturedModel[], showBrowseAll = true): number {
return showBrowseAll ? featuredModels.length : featuredModels.length - 1
}
/**
* Check if the selected index is the "Browse all" option
*/
export function isBrowseAllSelected(selectedIndex: number): boolean {
const featuredModels = getAllFeaturedModels()
export function isBrowseAllSelected(selectedIndex: number, featuredModels: FeaturedModel[]): boolean {
return selectedIndex === featuredModels.length
}
/**
* Get the featured model at the given index, or null if "Browse all" is selected
*/
export function getFeaturedModelAtIndex(index: number): FeaturedModel | null {
const featuredModels = getAllFeaturedModels()
export function getFeaturedModelAtIndex(index: number, featuredModels: FeaturedModel[]): FeaturedModel | null {
if (index >= 0 && index < featuredModels.length) {
return featuredModels[index]
}
+4
View File
@@ -88,6 +88,10 @@ export const HelpPanelContent: React.FC<HelpPanelContentProps> = ({ onClose }) =
{" "}
<Text color="white">/clear</Text> - Start a fresh task
</Text>
<Text>
{" "}
<Text color="white">/q</Text> - Quit Cline
</Text>
</Box>
<Text>
+112
View File
@@ -0,0 +1,112 @@
import { render } from "ink-testing-library"
// biome-ignore lint/correctness/noUnusedImports: React must be in scope for JSX in this test file.
import React from "react"
import { beforeEach, describe, expect, it, vi } from "vitest"
// Mock ink's useApp
const mockExit = vi.fn()
vi.mock("ink", async (importOriginal) => {
const actual = await importOriginal<typeof import("ink")>()
return {
...actual,
useApp: () => ({ exit: mockExit }),
}
})
// Mock child_process
vi.mock("child_process", () => ({
execSync: vi.fn().mockReturnValue(""),
exec: vi.fn(),
}))
// Mock dependencies
vi.mock("@/core/controller/slash/getAvailableSlashCommands", () => ({
getAvailableSlashCommands: vi.fn().mockResolvedValue({ commands: [] }),
}))
vi.mock("@/core/storage/StateManager", () => ({
StateManager: {
get: () => ({
getGlobalSettingsKey: vi.fn().mockReturnValue("act"),
getGlobalStateKey: vi.fn().mockReturnValue([]),
getApiConfiguration: vi.fn().mockReturnValue({}),
}),
},
}))
vi.mock("@/services/telemetry", () => ({
telemetryService: {
captureHostEvent: vi.fn(),
},
}))
vi.mock("@shared/services/Session", () => ({
Session: {
get: () => ({
getStats: vi.fn().mockReturnValue({}),
}),
},
}))
vi.mock("../context/TaskContext", () => ({
useTaskContext: () => ({
controller: {},
clearState: vi.fn(),
}),
useTaskState: () => ({
clineMessages: [],
}),
}))
vi.mock("../hooks/useStateSubscriber", () => ({
useIsSpinnerActive: () => ({ isActive: false, startTime: 0 }),
}))
import { ChatView } from "./ChatView"
// Helper to wait for async state updates
const delay = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms))
describe("Quit Command (/q and /exit)", () => {
const mockOnExit = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it("should exit the application when /q is selected from slash menu", async () => {
const { stdin } = render(<ChatView onExit={mockOnExit} />)
await delay()
// Type /q
stdin.write("/q")
await delay()
// Press Enter
stdin.write("\r")
// handleExit has a 150ms timeout
await delay(200)
expect(mockExit).toHaveBeenCalled()
expect(mockOnExit).toHaveBeenCalled()
})
it("should exit the application when /exit is selected from slash menu", async () => {
const { stdin } = render(<ChatView onExit={mockOnExit} />)
await delay()
// Type /exit
stdin.write("/exit")
await delay()
// Press Enter
stdin.write("\r")
// handleExit has a 150ms timeout
await delay(200)
expect(mockExit).toHaveBeenCalled()
expect(mockOnExit).toHaveBeenCalled()
})
})
+6 -3
View File
@@ -25,6 +25,7 @@ import { supportsReasoningEffortForModel } from "@/utils/model-utils"
import { version as CLI_VERSION } from "../../package.json"
import { COLORS } from "../constants/colors"
import { useStdinContext } from "../context/StdinContext"
import { useClineFeaturedModels } from "../hooks/useClineFeaturedModels"
import { useOcaAuth } from "../hooks/useOcaAuth"
import { isMouseEscapeSequence } from "../utils/input"
import { applyBedrockConfig, applyProviderConfig } from "../utils/provider-config"
@@ -161,6 +162,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
)
const [isPickingFeaturedModel, setIsPickingFeaturedModel] = useState(initialMode === "featured-models")
const [featuredModelIndex, setFeaturedModelIndex] = useState(0)
const featuredModels = useClineFeaturedModels()
const [isPickingProvider, setIsPickingProvider] = useState(false)
const [isPickingLanguage, setIsPickingLanguage] = useState(false)
const [isEnteringApiKey, setIsEnteringApiKey] = useState(false)
@@ -1292,7 +1294,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
// Featured model picker mode (Cline provider)
if (isPickingFeaturedModel) {
const maxIndex = getFeaturedModelMaxIndex()
const maxIndex = getFeaturedModelMaxIndex(featuredModels)
if (key.escape) {
setIsPickingFeaturedModel(false)
@@ -1306,12 +1308,12 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
} else if (key.downArrow) {
setFeaturedModelIndex((prev) => (prev < maxIndex ? prev + 1 : 0))
} else if (key.return) {
if (isBrowseAllSelected(featuredModelIndex)) {
if (isBrowseAllSelected(featuredModelIndex, featuredModels)) {
// Switch to full ModelPicker
setIsPickingFeaturedModel(false)
setIsPickingModel(true)
} else {
const selectedModel = getFeaturedModelAtIndex(featuredModelIndex)
const selectedModel = getFeaturedModelAtIndex(featuredModelIndex, featuredModels)
if (selectedModel && pickingModelKey) {
handleModelSelect(selectedModel.id)
setIsPickingFeaturedModel(false)
@@ -1522,6 +1524,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
const label = pickingModelKey === "actModelId" ? "Model ID (Act)" : "Model ID (Plan)"
return (
<FeaturedModelPicker
featuredModels={featuredModels}
helpText="Arrows to navigate, Enter to select, Esc to cancel"
selectedIndex={featuredModelIndex}
title={`Select: ${label}`}
+12 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"
import { getAllFeaturedModels } from "./featured-models"
import { getAllFeaturedModels, mapRecommendedModelsToFeaturedModels } from "./featured-models"
describe("featured models", () => {
it("includes display names for all featured models", () => {
@@ -9,4 +9,15 @@ describe("featured models", () => {
expect(model.name).toBeTruthy()
}
})
it("fills free model metadata from fallback when upstream payload is sparse", () => {
const models = mapRecommendedModelsToFeaturedModels({
recommended: [],
free: [{ id: "trinity-large-preview:free", name: "trinity-large-preview:free", description: "", tags: [] }],
})
expect(models.free[0]?.name).toBe("Arcee AI Trinity Large Preview")
expect(models.free[0]?.description).toBe("Arcee AI's advanced large preview model in the Trinity series")
expect(models.free[0]?.labels).toContain("FREE")
})
})
+76 -55
View File
@@ -2,6 +2,7 @@
* Featured models shown in the Cline model picker during onboarding
* These are curated models that work well with Cline
*/
import { CLINE_RECOMMENDED_MODELS_FALLBACK } from "@shared/cline/recommended-models"
export interface FeaturedModel {
id: string
@@ -10,61 +11,81 @@ export interface FeaturedModel {
labels: string[]
}
export const FEATURED_MODELS: { recommended: FeaturedModel[]; free: FeaturedModel[] } = {
recommended: [
{
id: "google/gemini-3.1-pro-preview",
name: "Gemini 3.1 Pro Preview",
description: "Latest Gemini release with 1m ctx window and strong coding performance",
labels: ["NEW"],
},
{
id: "anthropic/claude-sonnet-4.6",
name: "Claude Sonnet 4.6",
description: "Latest Sonnet release with strong coding and agent performance",
labels: ["NEW"],
},
{
id: "anthropic/claude-opus-4.6",
name: "Claude Opus 4.6",
description: "Most intelligent model for agents and coding",
labels: ["BEST"],
},
{
id: "openai/gpt-5.2-codex",
name: "GPT 5.2 Codex",
description: "OpenAI's latest with strong coding abilities",
labels: ["HOT"],
},
],
free: [
{
id: "minimax/minimax-m2.5",
name: "MiniMax M2.5",
description: "MiniMax-M2.5 is a lightweight, state-of-the-art LLM optimized for coding and agentic workflows",
labels: ["FREE"],
},
{
id: "z-ai/glm-5",
name: "Z-AI GLM5",
description: "Z.AI's latest GLM 5 model with strong coding and agent performance",
labels: ["FREE"],
},
{
id: "kwaipilot/kat-coder-pro",
name: "KAT Coder Pro",
description: "KwaiKAT's most advanced agentic coding model in the KAT-Coder series",
labels: ["FREE"],
},
{
id: "arcee-ai/trinity-large-preview:free",
name: "Trinity Large Preview",
description: "Arcee AI's advanced large preview model in the Trinity series",
labels: ["FREE"],
},
],
type RecommendedModelLike = {
id: string
name: string
description: string
tags: string[]
}
export function getAllFeaturedModels(): FeaturedModel[] {
return [...FEATURED_MODELS.recommended, ...FEATURED_MODELS.free]
export interface FeaturedModelsByTier {
recommended: FeaturedModel[]
free: FeaturedModel[]
}
interface RecommendedModelsByTier {
recommended: RecommendedModelLike[]
free: RecommendedModelLike[]
}
function toFeaturedModel(model: RecommendedModelLike): FeaturedModel {
return {
id: model.id,
name: model.name,
description: model.description,
labels: model.tags,
}
}
function getModelIdSuffix(id: string): string {
const lastSlashIndex = id.lastIndexOf("/")
return lastSlashIndex >= 0 ? id.slice(lastSlashIndex + 1) : id
}
function findFallbackFeaturedModelById(models: FeaturedModel[], id: string): FeaturedModel | undefined {
const idSuffix = getModelIdSuffix(id)
return models.find((model) => model.id === id || getModelIdSuffix(model.id) === idSuffix)
}
function mapRecommendedModelToFeaturedModelWithFallback(
model: RecommendedModelLike,
fallbackModels: FeaturedModel[],
defaultLabels: string[] = [],
): FeaturedModel {
const fallbackModel = findFallbackFeaturedModelById(fallbackModels, model.id)
const upstreamNameLooksLikeFallback = model.name === model.id || model.name.trim().length === 0
const name = upstreamNameLooksLikeFallback ? (fallbackModel?.name ?? model.name) : model.name
const description = model.description.trim().length > 0 ? model.description : (fallbackModel?.description ?? "")
const labels = model.tags.length > 0 ? model.tags : (fallbackModel?.labels ?? defaultLabels)
return {
id: model.id,
name,
description,
labels,
}
}
export const FEATURED_MODELS: FeaturedModelsByTier = {
recommended: CLINE_RECOMMENDED_MODELS_FALLBACK.recommended.map(toFeaturedModel),
free: CLINE_RECOMMENDED_MODELS_FALLBACK.free.map(toFeaturedModel),
}
export function getAllFeaturedModels(modelsByTier: FeaturedModelsByTier = FEATURED_MODELS): FeaturedModel[] {
return [...modelsByTier.recommended, ...modelsByTier.free]
}
export function mapRecommendedModelsToFeaturedModels(data: RecommendedModelsByTier): FeaturedModelsByTier {
return {
recommended: data.recommended.map((model) =>
mapRecommendedModelToFeaturedModelWithFallback(model, FEATURED_MODELS.recommended),
),
free: data.free.map((model) => mapRecommendedModelToFeaturedModelWithFallback(model, FEATURED_MODELS.free, ["FREE"])),
}
}
export function withFeaturedModelFallback(modelsByTier: FeaturedModelsByTier): FeaturedModelsByTier {
const recommended = modelsByTier.recommended.length > 0 ? modelsByTier.recommended : FEATURED_MODELS.recommended
const free = modelsByTier.free.length > 0 ? modelsByTier.free : FEATURED_MODELS.free
return { recommended, free }
}
+34
View File
@@ -0,0 +1,34 @@
import { useEffect, useState } from "react"
import { refreshClineRecommendedModels } from "@/core/controller/models/refreshClineRecommendedModels"
import {
type FeaturedModel,
getAllFeaturedModels,
mapRecommendedModelsToFeaturedModels,
withFeaturedModelFallback,
} from "../constants/featured-models"
export function useClineFeaturedModels(): FeaturedModel[] {
const [featuredModels, setFeaturedModels] = useState<FeaturedModel[]>(() => getAllFeaturedModels())
useEffect(() => {
let cancelled = false
void (async () => {
try {
const recommendedModels = await refreshClineRecommendedModels()
const mappedModels = mapRecommendedModelsToFeaturedModels(recommendedModels)
const modelsWithFallback = withFeaturedModelFallback(mappedModels)
if (!cancelled) {
setFeaturedModels(getAllFeaturedModels(modelsWithFallback))
}
} catch {
// Keep local fallback models on error.
}
})()
return () => {
cancelled = true
}
}, [])
return featuredModels
}
+82
View File
@@ -0,0 +1,82 @@
import { useState } from "react"
import { Controller } from "@/core/controller"
import { getAvailableSlashCommands } from "@/core/controller/slash/getAvailableSlashCommands"
import { EmptyRequest, SlashCommandInfo } from "@/shared/proto/index.cline"
import { CLI_ONLY_COMMANDS } from "@/shared/slashCommands"
import { fuzzyFilter } from "../utils/fuzzy-search"
interface SlashQueryInfo {
inSlashMode: boolean
query: string
slashIndex: number
}
const EMPTY_RESULT = { cmds: [], allCmds: [], slashInfo: { inSlashMode: false, query: "", slashIndex: -1 } }
export const useFilteredSlashCommands = (
ctrl: Controller,
textInput: string,
cursorPos: number,
): { cmds: SlashCommandInfo[]; allCmds: SlashCommandInfo[]; slashInfo: SlashQueryInfo } => {
const [allCommands, setAllCommands] = useState<SlashCommandInfo[]>([])
if (!ctrl) return EMPTY_RESULT
getAvailableSlashCommands(ctrl, EmptyRequest.create())
.then((response) => {
const cliCommands = response.commands.filter((cmd) => cmd.cliCompatible !== false)
const sorted = [...CLI_ONLY_COMMANDS, ...sortCommandsWorkflowsFirst(cliCommands)]
setAllCommands(sorted)
})
.catch(() => {
setAllCommands([])
})
const slashInfo = extractSlashQuery(textInput, cursorPos)
const filteredCmds = slashInfo.inSlashMode ? fuzzyFilter(allCommands, slashInfo.query, (cmd) => cmd.name) : []
return { cmds: filteredCmds, allCmds: allCommands, slashInfo }
}
/**
* Sort commands with workflows (custom section) first, then default commands.
*/
function sortCommandsWorkflowsFirst(commands: SlashCommandInfo[]): SlashCommandInfo[] {
return [...commands.filter((cmd) => cmd.section === "custom"), ...commands.filter((cmd) => cmd.section !== "custom")]
}
function extractSlashQuery(text: string, cursorPosition?: number): SlashQueryInfo {
// Use text up to cursor position (or full text if no cursor position provided)
const beforeCursor = cursorPosition !== undefined ? text.slice(0, cursorPosition) : text
// Find the last slash before cursor
const slashIndex = beforeCursor.lastIndexOf("/")
if (slashIndex === -1) {
return { inSlashMode: false, query: "", slashIndex: -1 }
}
// Slash must be at start or preceded by whitespace
const charBeforeSlash = slashIndex > 0 ? beforeCursor[slashIndex - 1] : null
if (charBeforeSlash !== null && !/\s/.test(charBeforeSlash)) {
return { inSlashMode: false, query: "", slashIndex: -1 }
}
// Get text after slash (up to cursor)
const textAfterSlash = beforeCursor.slice(slashIndex + 1)
// If there's whitespace after slash, we're not in slash mode anymore
if (/\s/.test(textAfterSlash)) {
return { inSlashMode: false, query: "", slashIndex: -1 }
}
// Check if there's already a completed slash command earlier in the text
// (only first slash command per message is processed)
const firstSlashCommandRegex = /(^|\s)\/[a-zA-Z0-9_.-]+\s/
const textBeforeCurrentSlash = text.slice(0, slashIndex)
if (firstSlashCommandRegex.test(textBeforeCurrentSlash)) {
return { inSlashMode: false, query: "", slashIndex: -1 }
}
return {
inSlashMode: true,
query: textAfterSlash,
slashIndex,
}
}
+4 -30
View File
@@ -73,24 +73,7 @@ async function disposeTelemetryServices(): Promise<void> {
await Promise.allSettled([telemetryService.dispose(), PostHogClientProvider.getInstance().dispose()])
}
/**
* Restore yoloModeToggled to its original value from before this CLI session.
* This ensures the --yolo flag is session-only and doesn't leak into future runs.
* Must be called before flushPendingState so the restored value gets persisted.
*/
function restoreYoloState(): void {
if (savedYoloModeToggled !== null) {
try {
StateManager.get().setGlobalState("yoloModeToggled", savedYoloModeToggled)
savedYoloModeToggled = null
} catch {
// StateManager may not be initialized (e.g., early exit before init)
}
}
}
async function disposeCliContext(ctx: CliContext): Promise<void> {
restoreYoloState()
await ctx.controller.stateManager.flushPendingState()
await ctx.controller.dispose()
await ErrorService.get().dispose()
@@ -203,12 +186,10 @@ function applyTaskOptions(options: TaskOptions): void {
telemetryService.captureHostEvent("max_consecutive_mistakes_flag", String(maxConsecutiveMistakes))
}
// Override yolo mode only if --yolo flag is explicitly passed.
// The original value is saved in initializeCli and restored on exit.
// Set yolo mode as a session-scoped override so AutoApprove picks it up,
// but it is never persisted to disk (setSessionOverride never touches pendingGlobalState).
if (options.yolo) {
const state = StateManager.get()
savedYoloModeToggled = state.getGlobalSettingsKey("yoloModeToggled") ?? false
state.setGlobalState("yoloModeToggled", true)
StateManager.get().setSessionOverride("yoloModeToggled", true)
telemetryService.captureHostEvent("yolo_flag", "true")
}
@@ -313,9 +294,6 @@ let activeContext: CliContext | null = null
let isShuttingDown = false
// Track if we're in plain text mode (no Ink UI) - set by runTask when piped stdin detected
let isPlainTextMode = false
// Track the original yoloModeToggled value from before this CLI session so we can restore it on exit.
// The --yolo flag should only affect the current invocation, not persist across runs.
let savedYoloModeToggled: boolean | null = null
/**
* Wait for stdout to fully drain before exiting.
@@ -357,10 +335,6 @@ function setupSignalHandlers() {
printWarning(`${signal} received, shutting down...`)
try {
// Restore yolo state before any cleanup - this is idempotent and safe
// even if disposeCliContext also calls it (restoreYoloState checks savedYoloModeToggled !== null)
restoreYoloState()
if (activeContext) {
const task = activeContext.controller.task
if (task) {
@@ -823,7 +797,7 @@ devCommand
* If `welcomeViewCompleted` is undefined (first run), checks if ANY provider has credentials
* and sets the flag accordingly.
*/
async function isAuthConfigured(): Promise<boolean> {
export async function isAuthConfigured(): Promise<boolean> {
const stateManager = StateManager.get()
// Check welcomeViewCompleted first - this is the single source of truth
+1 -72
View File
@@ -3,15 +3,6 @@
* Handles detection, filtering, and insertion of slash commands
*/
import type { SlashCommandInfo } from "@shared/proto/cline/slash"
import { fuzzyFilter } from "./fuzzy-search"
export interface SlashQueryInfo {
inSlashMode: boolean
query: string
slashIndex: number
}
export interface VisibleWindow<T> {
items: T[]
startIndex: number
@@ -22,7 +13,7 @@ export interface VisibleWindow<T> {
* Centers the selected item in the visible window when possible.
* Returns the visible items and the start index for selection tracking.
*/
export function getVisibleWindow<T>(items: T[], selectedIndex: number, maxVisible: number = 5): VisibleWindow<T> {
export function getVisibleWindow<T>(items: T[], selectedIndex: number, maxVisible = 5): VisibleWindow<T> {
if (items.length <= maxVisible) {
return { items, startIndex: 0 }
}
@@ -39,68 +30,6 @@ export function getVisibleWindow<T>(items: T[], selectedIndex: number, maxVisibl
return { items: items.slice(startIndex, endIndex), startIndex }
}
/**
* Sort commands with workflows (custom section) first, then default commands.
*/
export function sortCommandsWorkflowsFirst(commands: SlashCommandInfo[]): SlashCommandInfo[] {
return [...commands.filter((cmd) => cmd.section === "custom"), ...commands.filter((cmd) => cmd.section !== "custom")]
}
/**
* Extract slash command query from input text.
* Returns info about whether we're in slash mode and what the query is.
* Takes cursor position to only examine text before cursor (matching webview behavior).
*/
export function extractSlashQuery(text: string, cursorPosition?: number): SlashQueryInfo {
// Use text up to cursor position (or full text if no cursor position provided)
const beforeCursor = cursorPosition !== undefined ? text.slice(0, cursorPosition) : text
// Find the last slash before cursor
const slashIndex = beforeCursor.lastIndexOf("/")
if (slashIndex === -1) {
return { inSlashMode: false, query: "", slashIndex: -1 }
}
// Slash must be at start or preceded by whitespace
const charBeforeSlash = slashIndex > 0 ? beforeCursor[slashIndex - 1] : null
if (charBeforeSlash !== null && !/\s/.test(charBeforeSlash)) {
return { inSlashMode: false, query: "", slashIndex: -1 }
}
// Get text after slash (up to cursor)
const textAfterSlash = beforeCursor.slice(slashIndex + 1)
// If there's whitespace after slash, we're not in slash mode anymore
if (/\s/.test(textAfterSlash)) {
return { inSlashMode: false, query: "", slashIndex: -1 }
}
// Check if there's already a completed slash command earlier in the text
// (only first slash command per message is processed)
const firstSlashCommandRegex = /(^|\s)\/[a-zA-Z0-9_.-]+\s/
const textBeforeCurrentSlash = text.slice(0, slashIndex)
if (firstSlashCommandRegex.test(textBeforeCurrentSlash)) {
return { inSlashMode: false, query: "", slashIndex: -1 }
}
return {
inSlashMode: true,
query: textAfterSlash,
slashIndex,
}
}
/**
* Filter commands using fuzzy matching
*/
export function filterCommands(commands: SlashCommandInfo[], query: string): SlashCommandInfo[] {
if (!query) {
return commands
}
return fuzzyFilter(commands, query, (cmd) => cmd.name)
}
/**
* Insert a slash command at the given slash index, replacing any partial query
*/
+1
View File
@@ -89,6 +89,7 @@ export const TOOL_DESCRIPTIONS: Record<string, { ask: string; say: string }> = {
attempt_completion: { ask: "wants to complete the task", say: "completed the task" },
new_task: { ask: "wants to create a new task", say: "created a new task" },
focus_chain: { ask: "wants to update the todo list", say: "updated the todo list" },
change_directory: { ask: "wants to change working directory", say: "changed working directory" },
}
/**
+42
View File
@@ -73,6 +73,8 @@ Cline stores configuration in `~/.cline/data/`:
├── data/ # Configuration directory
│ ├── globalState.json # Global settings
│ ├── secrets.json # API keys (encrypted)
│ ├── settings/ # Settings files
│ │ └── cline_mcp_settings.json # MCP server configuration
│ ├── workspace/ # Workspace-specific state
│ └── tasks/ # Task history and data
└── log/ # Log files
@@ -172,6 +174,46 @@ cline --config ~/.cline-work "review this PR"
cline --config ~/.cline-personal "help me with this side project"
```
## MCP Server Configuration
Cline CLI supports [MCP (Model Context Protocol)](/mcp/mcp-overview) servers, giving you access to external tools and data sources directly from the terminal. The CLI uses the same MCP configuration format as the VS Code extension.
### Setting Up MCP Servers
To configure MCP servers for the CLI, create or edit the settings file at:
```
~/.cline/data/settings/cline_mcp_settings.json
```
The file uses the same JSON format as the VS Code extension:
```json
{
"mcpServers": {
"my-server": {
"command": "node",
"args": ["/path/to/server.js"],
"env": {
"API_KEY": "your_api_key"
},
"alwaysAllow": ["tool1", "tool2"],
"disabled": false
}
}
}
```
For the full configuration reference including STDIO and SSE transport types, see [Adding and Configuring MCP Servers](/mcp/adding-and-configuring-servers).
<Note>
The CLI does not yet have a `/mcp` slash command for managing MCP servers interactively. For now, you'll need to edit the `cline_mcp_settings.json` file directly.
</Note>
### Custom Config Directory
If you use the `CLINE_DIR` environment variable or `--config` flag, the MCP settings file will be located at `<your-config-dir>/data/settings/cline_mcp_settings.json` instead.
## Configuration for Local Providers
### Ollama
+9
View File
@@ -207,6 +207,15 @@ Chains multiple Cline invocations together for creative multi-step workflows.
| Session summary | ✓ | - |
| JSON output | - | `--json` |
| Piped input | - | ✓ |
| [MCP servers](/cline-cli/configuration#mcp-server-configuration) | ✓ | ✓ |
## MCP Server Support
Cline CLI supports [MCP (Model Context Protocol)](/mcp/mcp-overview) servers, the same extensibility system available in the VS Code extension. MCP servers give Cline access to external tools and data sources, from databases and APIs to browser automation and project management.
To use MCP servers with the CLI, add your server configuration to `~/.cline/data/settings/cline_mcp_settings.json`. The format is identical to the VS Code extension.
[Configure MCP servers for the CLI →](/cline-cli/configuration#mcp-server-configuration)
## Learn More
+2 -1
View File
@@ -286,7 +286,8 @@
{
"group": "Control Other Cline Features",
"pages": [
"enterprise-solutions/configuration/infrastructure-configuration/control-other-cline-features/yolo-mode"
"enterprise-solutions/configuration/infrastructure-configuration/control-other-cline-features/yolo-mode",
"enterprise-solutions/configuration/infrastructure-configuration/control-other-cline-features/mcp-marketplace"
]
},
{
@@ -0,0 +1,298 @@
---
title: "MCP Marketplace"
sidebarTitle: "MCP Marketplace"
description: "Enterprise controls for MCP Marketplace access, server allowlisting, and remote MCP server management"
---
The MCP Marketplace lets developers discover and install MCP servers that extend Cline's capabilities. For Enterprise administrators, this page covers how to control marketplace access, restrict which servers are available, and push pre-configured MCP servers to your organization.
<Note>
For complete details about the MCP Marketplace and how developers use it, see [MCP Made Easy](/mcp/mcp-marketplace).
</Note>
## Overview
Enterprise administrators have four configuration options to govern MCP server usage across their organization:
| Setting | Purpose |
|---------|---------|
| `mcpMarketplaceEnabled` | Enable or disable the MCP Marketplace entirely |
| `allowedMCPServers` | Restrict the marketplace to only approved MCP servers |
| `remoteMCPServers` | Push pre-configured remote MCP servers to all users |
| `blockPersonalRemoteMCPServers` | Prevent users from adding their own remote MCP servers |
These settings are applied through your organization's [remote configuration](/enterprise-solutions/configuration/remote-configuration/overview) and take effect immediately for all team members.
## Disabling the MCP Marketplace
To completely disable the MCP Marketplace for your organization, set `mcpMarketplaceEnabled` to `false`:
```json
{
"mcpMarketplaceEnabled": false
}
```
When `mcpMarketplaceEnabled` is set to `false`:
- The MCP Marketplace tab is hidden from all users
- Users cannot browse or install MCP servers from the marketplace
- Locally configured MCP servers are blocked
- Enterprise policy takes precedence over individual preferences
When `mcpMarketplaceEnabled` is set to `true` or omitted:
- Users can freely browse and install MCP servers from the marketplace
- No organizational restrictions apply to marketplace access
<Warning>
Disabling the marketplace entirely also blocks locally configured MCP servers. If you want to allow specific servers while restricting others, use the allowlist approach described below instead.
</Warning>
## Restricting the Marketplace to Approved Servers
Rather than disabling the marketplace entirely, you can restrict it to a curated list of approved MCP servers using the `allowedMCPServers` setting. This is the recommended approach for most enterprises — it lets developers benefit from MCP while ensuring only vetted servers are available.
### Configuration
Add an `allowedMCPServers` array to your remote configuration. Each entry requires an `id` field set to the server's GitHub repository path:
```json
{
"allowedMCPServers": [
{ "id": "github.com/modelcontextprotocol/server-filesystem" },
{ "id": "github.com/modelcontextprotocol/server-github" },
{ "id": "github.com/your-org/internal-mcp-server" }
]
}
```
### How It Works
When `allowedMCPServers` is configured:
- The marketplace catalog is filtered to show **only** the servers in your allowlist
- Users can browse, view details, and install any server on the list
- Servers not on the list are completely hidden from the marketplace
- The allowlist applies to all team members in the organization
When `allowedMCPServers` is omitted or `undefined`:
- The full marketplace catalog is available with no restrictions
When `allowedMCPServers` is set to an empty array (`[]`):
- The marketplace shows no servers — effectively disabling installation while keeping the UI visible
### Finding Server IDs
The `id` for each allowed server is its GitHub repository path (without the `https://` prefix). For example:
| Server | ID |
|--------|----|
| Filesystem | `github.com/modelcontextprotocol/server-filesystem` |
| GitHub | `github.com/modelcontextprotocol/server-github` |
| Custom internal server | `github.com/your-org/your-mcp-server` |
You can find the correct ID by checking the `githubUrl` field of any server in the [MCP Marketplace](/mcp/mcp-marketplace) and removing the `https://` prefix.
## Pushing Pre-Configured Remote MCP Servers
Use `remoteMCPServers` to push MCP servers directly to all users without requiring them to install anything from the marketplace. This is ideal for internal MCP servers or third-party servers that need specific configuration.
### Configuration
```json
{
"remoteMCPServers": [
{
"name": "Internal Code Search",
"url": "https://mcp.internal.yourcompany.com/code-search",
"alwaysEnabled": true,
"headers": {
"Authorization": "Bearer ${AUTH_TOKEN}"
}
},
{
"name": "Documentation Server",
"url": "https://mcp.internal.yourcompany.com/docs",
"alwaysEnabled": false
}
]
}
```
### Remote Server Options
Each remote MCP server entry supports the following fields:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | Yes | Display name for the server |
| `url` | string | Yes | The URL endpoint of the MCP server |
| `alwaysEnabled` | boolean | No | When `true`, users cannot disable this server |
| `headers` | object | No | Custom HTTP headers for authentication |
### Always-Enabled Servers
When `alwaysEnabled` is set to `true`:
- The server is automatically active for all users
- Users cannot toggle the server off
- The server appears in the user's MCP configuration but the disable control is locked
- This is useful for compliance, security, or internal tooling servers that must always be available
## Blocking Personal Remote MCP Servers
To prevent users from adding their own remote MCP servers, set `blockPersonalRemoteMCPServers` to `true`:
```json
{
"blockPersonalRemoteMCPServers": true
}
```
When `blockPersonalRemoteMCPServers` is `true`:
- Users cannot add or configure remote MCP servers on their own
- Only servers defined in the organization's `remoteMCPServers` configuration are available
- This ensures all remote MCP connections go through approved, organization-managed endpoints
When `blockPersonalRemoteMCPServers` is `false` or omitted:
- Users can freely add their own remote MCP server connections
## Combined Configuration Examples
### Locked-Down Environment
For organizations that need strict control over all MCP server access:
```json
{
"mcpMarketplaceEnabled": true,
"allowedMCPServers": [
{ "id": "github.com/modelcontextprotocol/server-filesystem" },
{ "id": "github.com/modelcontextprotocol/server-github" }
],
"remoteMCPServers": [
{
"name": "Internal API Gateway",
"url": "https://mcp.internal.yourcompany.com/gateway",
"alwaysEnabled": true,
"headers": {
"X-Api-Key": "org-managed-key"
}
}
],
"blockPersonalRemoteMCPServers": true
}
```
This configuration:
- Allows the marketplace but limits it to two approved servers
- Pushes an always-enabled internal MCP server to all users
- Blocks users from adding their own remote MCP servers
### Open Environment with Internal Servers
For organizations that want flexibility with internal server access:
```json
{
"remoteMCPServers": [
{
"name": "Company Knowledge Base",
"url": "https://mcp.yourcompany.com/kb",
"alwaysEnabled": true
}
]
}
```
This configuration:
- Leaves the full marketplace open (no `allowedMCPServers` restriction)
- Ensures all developers have access to the company knowledge base
- Allows users to add their own remote MCP servers
### Marketplace Disabled with Internal Servers Only
For organizations that want to fully manage the MCP experience:
```json
{
"mcpMarketplaceEnabled": false,
"remoteMCPServers": [
{
"name": "Approved Code Assistant",
"url": "https://mcp.internal.yourcompany.com/code-assist",
"alwaysEnabled": true
},
{
"name": "Internal Docs Search",
"url": "https://mcp.internal.yourcompany.com/docs",
"alwaysEnabled": true
}
],
"blockPersonalRemoteMCPServers": true
}
```
This configuration:
- Disables the marketplace completely
- Provides only organization-managed MCP servers
- Prevents users from adding any additional remote servers
## Enterprise Policy Recommendations
### Recommended Approach
Most organizations should **use the allowlist** (`allowedMCPServers`) rather than disabling the marketplace entirely. This gives developers access to useful tools while ensuring security review of each server.
<AccordionGroup>
<Accordion title="Security Review Process" icon="shield">
Before adding an MCP server to your allowlist:
- Review the server's source code on GitHub
- Evaluate the server's permissions and data access patterns
- Check for active maintenance and security practices
- Assess whether the server's data handling meets your compliance requirements
- Test the server in a sandbox environment before approving
</Accordion>
<Accordion title="Internal MCP Servers" icon="building">
For internal tooling, use `remoteMCPServers` with `alwaysEnabled: true`:
- Connect Cline to internal APIs, databases, and knowledge bases
- Ensure consistent access across all developers
- Manage authentication centrally through custom headers
- Use `blockPersonalRemoteMCPServers` to prevent shadow IT
</Accordion>
<Accordion title="Compliance Considerations" icon="clipboard-check">
MCP servers can access external APIs and process data:
- Audit which servers handle sensitive data
- Ensure servers comply with your data residency requirements
- Document approved servers in your security policies
- Regularly review and update your allowlist
</Accordion>
</AccordionGroup>
### Recommendations by Organization Size
#### Small Teams (520 developers)
- **Marketplace:** Open or lightly restricted with an allowlist
- **Remote Servers:** Push internal servers as needed
- **Personal Servers:** Allow with guidance
- **Review Cadence:** Quarterly allowlist review
#### Medium Organizations (20100 developers)
- **Marketplace:** Restricted to an approved allowlist
- **Remote Servers:** Push internal servers with `alwaysEnabled`
- **Personal Servers:** Consider blocking (`blockPersonalRemoteMCPServers: true`)
- **Review Cadence:** Monthly allowlist review
#### Large Enterprises (100+ developers)
- **Marketplace:** Strictly restricted to a vetted allowlist
- **Remote Servers:** All MCP access through organization-managed servers
- **Personal Servers:** Blocked (`blockPersonalRemoteMCPServers: true`)
- **Review Cadence:** Formal approval process for new servers with security review
## Support & Questions
For help configuring MCP Marketplace policies:
- Review [Remote Configuration Overview](/enterprise-solutions/configuration/remote-configuration/overview)
- See [MCP Made Easy](/mcp/mcp-marketplace) for marketplace functionality details
- See [MCP Overview](/mcp/mcp-overview) for general MCP concepts
- Contact your Enterprise support representative
- Join our [Discord](https://discord.gg/cline) for community discussion
+15 -1
View File
@@ -9,7 +9,21 @@ Cline Enterprise integrates with your identity provider (IdP) via **WorkOS AuthK
This page describes, at a high level, how SSO is set up for Cline Enterprise using WorkOS AuthKit.
If you havent completed initial onboarding, start with [Onboarding](/enterprise-solutions/onboarding).
If you haven't completed initial onboarding, start with [Onboarding](/enterprise-solutions/onboarding).
### Video Walkthrough
<Frame>
<iframe
src="https://www.youtube.com/embed/QC7mzXLjIH8"
title="SSO Setup with WorkOS"
width="100%"
style={{ aspectRatio: "16/9" }}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</Frame>
## Where setup happens
SSO setup spans two places:
+2
View File
@@ -70,6 +70,8 @@ Cline does not come with any pre-installed MCP servers. You'll need to find and
## Integration with Cline
MCP servers work with both the **Cline VS Code extension** and the **[Cline CLI](/cline-cli/overview)**. If you use the CLI, see [MCP Server Configuration for the CLI](/cline-cli/configuration#mcp-server-configuration) to get set up.
Cline simplifies the building and use of MCP servers through its AI capabilities.
### Building MCP Servers
+1 -1
View File
@@ -19,7 +19,7 @@ Google Gemini is Google's family of multimodal AI models, offering some of the l
Cline supports the following Google Gemini models:
#### Gemini 3 Series (Latest)
- `gemini-3-pro-preview` (Default) - Latest pro model with 1M context, thinking support, and tiered pricing ($2.00-$4.00/M input)
- `gemini-3.1-pro-preview` (Default) - Latest pro model with 1M context, thinking support, and tiered pricing ($2.00-$4.00/M input)
- `gemini-3-flash-preview` - Fast model with 1M context and thinking level support ($0.30-$0.50/M input)
#### Gemini 2.5 Series
-23
View File
@@ -14,29 +14,6 @@ SambaNova provides fast AI inference on custom-built hardware, hosting popular o
3. **Create a Key:** Generate a new API key.
4. **Copy the Key:** Copy the API key immediately and store it securely.
### Supported Models
Cline supports the following SambaNova models:
#### Meta Llama Models
- `Llama-4-Maverick-17B-128E-Instruct` - Llama 4 Maverick with vision support ($0.63/$1.80 per 1M tokens)
- `Llama-4-Scout-17B-16E-Instruct` - Llama 4 Scout ($0.40/$0.70 per 1M tokens)
- `Meta-Llama-3.3-70B-Instruct` (Default) - Versatile 70B model with 128K context ($0.60/$1.20 per 1M tokens)
- `Meta-Llama-3.1-405B-Instruct` - Largest Llama model ($5.00/$10.00 per 1M tokens)
- `Meta-Llama-3.1-8B-Instruct` - Compact 8B model ($0.10/$0.20 per 1M tokens)
- `Meta-Llama-3.2-1B-Instruct` - Ultra-compact 1B model ($0.04/$0.08 per 1M tokens)
- `Meta-Llama-3.2-3B-Instruct` - Small 3B model ($0.08/$0.16 per 1M tokens)
#### DeepSeek Models
- `DeepSeek-R1` - Reasoning model ($5.00/$7.00 per 1M tokens)
- `DeepSeek-R1-Distill-Llama-70B` - Distilled reasoning model ($0.70/$1.40 per 1M tokens)
- `DeepSeek-V3-0324` - General-purpose model ($3.00/$4.50 per 1M tokens)
- `DeepSeek-V3.1` - Latest DeepSeek with hybrid reasoning ($3.00/$4.50 per 1M tokens)
#### Qwen Models
- `Qwen3-32B` - Dense 32B model ($0.40/$0.80 per 1M tokens)
- `QwQ-32B` - Reasoning-focused Qwen model ($0.50/$1.00 per 1M tokens)
### Configuration in Cline
1. **Open Cline Settings:** Click the settings icon (⚙️) in the Cline panel.
+3 -8
View File
@@ -56,15 +56,10 @@ Cline에 기여하는 것에 관심을 가져주셔서 감사합니다! 버그
- 변경사항이 기존 테스트에 영향을 미치는 경우 해당 테스트를 업데이트해 주세요
- 적절한 경우 단위 테스트와 통합 테스트를 모두 포함해 주세요
4. **Changesets를 활용한 버전 관리**
4. **버전/릴리스 노트 관리**
- 사용자에게 영향을 미치는 변경 사항이 있는 경우, `npm run changeset`을 실행하여 changeset을 생성해 주세요
- 적절한 버전 증가 옵션을 선택하세요:
- `major` 호환되지 않는 변경 (1.0.0 → 2.0.0)
- `minor` 새로운 기능 추가 (1.0.0 → 1.1.0)
- `patch` 버그 수정 (1.0.0 → 1.0.1)
- 영향을 설명하는 명확한 변경사항 메시지를 작성해 주세요
- 문서 변경만 있는 경우 changeset이 필요하지 않습니다
- 기여자는 PR에서 changelog-entry 파일을 만들 필요가 없습니다.
- 릴리스 버전 관리와 CHANGELOG 정리는 메인테이너가 릴리스 과정에서 수행합니다.
5. **커밋 가이드라인**
+46 -811
View File
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "3.66.0",
"version": "3.67.0",
"icon": "assets/icons/icon.png",
"workspaces": [
".",
@@ -437,14 +437,16 @@
"publish:marketplace:prerelease": "vsce publish --allow-package-secrets sendgrid --pre-release && ovsx publish --pre-release",
"publish:marketplace:nightly": "node ./scripts/publish-nightly.mjs",
"prepare": "npx husky",
"changeset": "changeset",
"version-packages": "changeset version",
"docs": "cd docs && npm run dev",
"docs:check-links": "cd docs && npm run check",
"docs:rename-file": "cd docs && npm run rename",
"report-issue": "node scripts/report-issue.js",
"storybook": "cd webview-ui && npm run storybook",
"cli:unlink": "cd cli && npm run unlink"
"cli:unlink": "cd cli && npm run unlink",
"eval:smoke:build": "npm run cli:build && npm run cli:link",
"eval:smoke:run": "npx tsx evals/smoke-tests/run-smoke-tests.ts",
"eval:smoke": "npm run eval:smoke:build && npm run eval:smoke:run",
"eval:smoke:ci": "npm run eval:smoke:build && npm run eval:smoke:run -- --trials 1 --parallel"
},
"lint-staged": {
"src/shared/storage/state-keys.ts": [
@@ -458,7 +460,6 @@
"devDependencies": {
"@biomejs/biome": "^2.3.14",
"@bufbuild/buf": "^1.54.0",
"@changesets/cli": "^2.27.12",
"@types/better-sqlite3": "^7.6.13",
"@types/chai": "^5.0.1",
"@types/clone-deep": "^4.0.4",
+15
View File
@@ -19,6 +19,8 @@ service ModelsService {
rpc getVsCodeLmModels(EmptyRequest) returns (VsCodeLmModelsArray);
// Refreshes and returns OpenRouter models
rpc refreshOpenRouterModelsRpc(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
// Refreshes and returns recommended and free Cline models
rpc refreshClineRecommendedModelsRpc(EmptyRequest) returns (ClineRecommendedModelsResponse);
// Refreshes and returns Hugging Face models
rpc refreshHuggingFaceModels(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
// Refreshes and returns OpenAI models
@@ -113,6 +115,18 @@ message OpenRouterCompatibleModelInfo {
map<string, OpenRouterModelInfo> models = 1;
}
message ClineRecommendedModel {
string id = 1;
string name = 2;
string description = 3;
repeated string tags = 4;
}
message ClineRecommendedModelsResponse {
repeated ClineRecommendedModel recommended = 1;
repeated ClineRecommendedModel free = 2;
}
// Request for fetching OpenAI models
message OpenAiModelsRequest {
Metadata metadata = 1;
@@ -448,6 +462,7 @@ enum ApiFormat {
OPENAI_CHAT = 2;
R1_CHAT = 3;
OPENAI_RESPONSES = 4;
OPENAI_RESPONSES_WEBSOCKET_MODE = 5;
}
// Model info for OpenAI-compatible models
+1 -1
View File
@@ -104,7 +104,7 @@ message Secrets {
optional string oca_refresh_token = 42;
optional string mcp_o_auth_secrets = 43;
optional string cline_api_key = 44;
optional string openai_codex_oauth_credentials = 47;
optional string openai_codex_oauth_credentials = 48;
}
// NOTE: Add new fields under API_HANDLER_SETTINGS_FIELDS or USER_SETTINGS_FIELDS
+2 -3
View File
@@ -1,4 +1,3 @@
import type * as vscode from "vscode"
import { WebviewProvider } from "./core/webview"
import "./utils/path" // necessary to have access to String.prototype.toPosix
@@ -10,6 +9,7 @@ import { clearOnboardingModelsCache } from "./core/controller/models/getClineOnb
import { HookDiscoveryCache } from "./core/hooks/HookDiscoveryCache"
import { HookProcessRegistry } from "./core/hooks/HookProcessRegistry"
import { StateManager } from "./core/storage/StateManager"
import { AgentConfigLoader } from "./core/task/tools/subagent/AgentConfigLoader"
import { ExtensionRegistryInfo } from "./registry"
import { audioRecordingService } from "./services/dictation/AudioRecordingService"
import { ErrorService } from "./services/error"
@@ -25,8 +25,6 @@ import { getBlobStoreSettingsFromEnv } from "./shared/services/worker/worker"
import { getLatestAnnouncementId } from "./utils/announcements"
import { arePathsEqual } from "./utils/path"
type SlimExtensionContext = Omit<vscode.ExtensionContext, "globalState" | "secrets" | "workspaceState">
/**
* Performs intialization for Cline that is common to all platforms.
*
@@ -157,6 +155,7 @@ export async function tearDown(): Promise<void> {
// Clean up audio recording service to ensure no orphaned processes
audioRecordingService.cleanup()
AgentConfigLoader.getInstance()?.dispose()
PostHogClientProvider.getInstance().dispose()
telemetryService.dispose()
ErrorService.get().dispose()
@@ -0,0 +1,59 @@
import "should"
import { openRouterDefaultModelInfo } from "@shared/api"
import sinon from "sinon"
import { ClineHandler } from "../cline"
describe("ClineHandler", () => {
afterEach(() => {
sinon.restore()
})
const createAsyncIterable = (data: any[] = []) => ({
[Symbol.asyncIterator]: async function* () {
yield* data
},
})
it("should handle usage-only chunks when delta is missing", async () => {
const handler = Object.create(ClineHandler.prototype) as ClineHandler
;(handler as any).options = {}
const fakeClient = {
chat: {
completions: {
create: sinon.stub().resolves(
createAsyncIterable([
{
choices: [{}],
usage: {
prompt_tokens: 17,
completion_tokens: 9,
},
},
]),
),
},
},
}
sinon.stub(handler as any, "ensureClient").resolves(fakeClient as any)
sinon.stub(handler, "getModel").returns({
id: "openai/gpt-4o-mini",
info: openRouterDefaultModelInfo,
})
const chunks: any[] = []
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
chunks.push(chunk)
}
chunks.should.deepEqual([
{
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: 0,
inputTokens: 17,
outputTokens: 9,
totalCost: 0,
},
])
})
})
@@ -0,0 +1,55 @@
import "should"
import sinon from "sinon"
import { FireworksHandler } from "../fireworks"
describe("FireworksHandler", () => {
afterEach(() => {
sinon.restore()
})
const createAsyncIterable = (data: any[] = []) => ({
[Symbol.asyncIterator]: async function* () {
yield* data
},
})
it("should handle usage-only chunks when delta is missing", async () => {
const handler = new FireworksHandler({
fireworksApiKey: "test-api-key",
fireworksModelId: "accounts/fireworks/models/llama-v3p1-8b-instruct",
})
const fakeClient = {
chat: {
completions: {
create: sinon.stub().resolves(
createAsyncIterable([
{
choices: [{}],
usage: {
prompt_tokens: 19,
completion_tokens: 4,
},
},
]),
),
},
},
}
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
const chunks: any[] = []
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
chunks.push(chunk)
}
chunks.should.deepEqual([
{
type: "usage",
inputTokens: 19,
outputTokens: 4,
cacheReadTokens: 0,
cacheWriteTokens: 0,
},
])
})
})
@@ -46,6 +46,8 @@ describe("LiteLlmHandler", () => {
}
beforeEach(() => {
fakeClient.chat.completions.create.resetHistory()
mockFetchForTesting(mockFetch, () => {
return new Promise((resolve) => {
doneMockingFetch = resolve
@@ -0,0 +1,60 @@
import "should"
import { openRouterDefaultModelInfo } from "@shared/api"
import sinon from "sinon"
import { OpenRouterHandler } from "../openrouter"
describe("OpenRouterHandler", () => {
afterEach(() => {
sinon.restore()
})
const createAsyncIterable = (data: any[] = []) => ({
[Symbol.asyncIterator]: async function* () {
yield* data
},
})
it("should handle usage-only chunks when delta is missing", async () => {
const handler = new OpenRouterHandler({
openRouterApiKey: "test-api-key",
})
const fakeClient = {
chat: {
completions: {
create: sinon.stub().resolves(
createAsyncIterable([
{
choices: [{}],
usage: {
prompt_tokens: 13,
completion_tokens: 5,
},
},
]),
),
},
},
}
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
sinon.stub(handler, "getModel").returns({
id: "openai/gpt-4o-mini",
info: openRouterDefaultModelInfo,
})
const chunks: any[] = []
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
chunks.push(chunk)
}
chunks.should.deepEqual([
{
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: 0,
inputTokens: 13,
outputTokens: 5,
totalCost: 0,
},
])
})
})
@@ -1,8 +1,19 @@
import "should"
import { openRouterDefaultModelId, openRouterDefaultModelInfo } from "@shared/api"
import sinon from "sinon"
import { VercelAIGatewayHandler } from "../vercel-ai-gateway"
describe("VercelAIGatewayHandler", () => {
afterEach(() => {
sinon.restore()
})
const createAsyncIterable = (data: any[] = []) => ({
[Symbol.asyncIterator]: async function* () {
yield* data
},
})
describe("getModel", () => {
it("should return configured model and info when both are provided", () => {
const customModelInfo = {
@@ -11,22 +22,22 @@ describe("VercelAIGatewayHandler", () => {
}
const handler = new VercelAIGatewayHandler({
openRouterModelId: "google/gemini-3-pro-preview",
openRouterModelId: "google/gemini-3.1-pro-preview",
openRouterModelInfo: customModelInfo,
})
const result = handler.getModel()
result.id.should.equal("google/gemini-3-pro-preview")
result.id.should.equal("google/gemini-3.1-pro-preview")
result.info.should.deepEqual(customModelInfo)
})
it("should preserve configured model ID when model info is missing", () => {
const handler = new VercelAIGatewayHandler({
openRouterModelId: "google/gemini-3-pro-preview",
openRouterModelId: "google/gemini-3.1-pro-preview",
})
const result = handler.getModel()
result.id.should.equal("google/gemini-3-pro-preview")
result.id.should.equal("google/gemini-3.1-pro-preview")
result.info.should.deepEqual(openRouterDefaultModelInfo)
})
@@ -38,4 +49,46 @@ describe("VercelAIGatewayHandler", () => {
result.info.should.deepEqual(openRouterDefaultModelInfo)
})
})
describe("createMessage", () => {
it("should handle usage-only chunks when delta is missing", async () => {
const handler = new VercelAIGatewayHandler({
vercelAiGatewayApiKey: "test-api-key",
})
const fakeClient = {
chat: {
completions: {
create: sinon.stub().resolves(
createAsyncIterable([
{
choices: [{}],
usage: {
prompt_tokens: 11,
completion_tokens: 7,
},
},
]),
),
},
},
}
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
const chunks: any[] = []
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
chunks.push(chunk)
}
chunks.should.deepEqual([
{
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: 0,
inputTokens: 11,
outputTokens: 7,
totalCost: 0,
},
])
})
})
})
+10 -5
View File
@@ -84,7 +84,8 @@ export class CerebrasHandler implements ApiHandler {
.map((block) => {
if (block.type === "text") {
return block.text
} else if (block.type === "image") {
}
if (block.type === "image") {
return "[Image content not supported in Cerebras]"
}
return ""
@@ -195,14 +196,18 @@ export class CerebrasHandler implements ApiHandler {
// Rate limit error - will be handled by retry decorator with patient backoff
const _limits = this.getRateLimits()
throw new Error(`Cerebras API rate limit exceeded.`)
} else if (error?.status === 401) {
}
if (error?.status === 401) {
throw new Error("Cerebras API authentication failed. Please check your API key.")
} else if (error?.status === 403) {
}
if (error?.status === 403) {
throw new Error("Cerebras API access denied. Please check your API key permissions.")
} else if (error?.status >= 500) {
}
if (error?.status >= 500) {
// Server errors - retryable
throw new Error(`Cerebras API server error (${error.status}): ${error.message || "Unknown server error"}`)
} else if (error?.status === 400) {
}
if (error?.status === 400) {
// Client errors - not retryable
throw new Error(`Cerebras API bad request: ${error.message || "Invalid request parameters"}`)
}
+7 -1
View File
@@ -168,7 +168,12 @@ export class ClineHandler implements ApiHandler {
// Reasoning tokens are returned separately from the content
// Skip reasoning content for Grok 4 models since it only displays "thinking" without providing useful information
if ("reasoning" in delta && delta.reasoning && !shouldSkipReasoningForModel(this.options.openRouterModelId)) {
if (
delta &&
"reasoning" in delta &&
delta.reasoning &&
!shouldSkipReasoningForModel(this.options.openRouterModelId)
) {
yield {
type: "reasoning",
reasoning: typeof delta.reasoning === "string" ? delta.reasoning : JSON.stringify(delta.reasoning),
@@ -183,6 +188,7 @@ export class ClineHandler implements ApiHandler {
See: https://openrouter.ai/docs/use-cases/reasoning-tokens#preserving-reasoning-blocks
*/
if (
delta &&
"reasoning_details" in delta &&
delta.reasoning_details &&
// @ts-expect-error-next-line
+1 -1
View File
@@ -71,7 +71,7 @@ export class FireworksHandler implements ApiHandler {
}
}
if (reasoning || ("reasoning_content" in delta && delta.reasoning_content)) {
if (reasoning || (delta && "reasoning_content" in delta && delta.reasoning_content)) {
yield {
type: "reasoning",
reasoning: delta.content || ((delta as any).reasoning_content as string | undefined) || "",
+1 -1
View File
@@ -157,7 +157,7 @@ export class GeminiHandler implements ApiHandler {
httpOptions: this.options.geminiBaseUrl ? { baseUrl: this.options.geminiBaseUrl } : undefined,
systemInstruction: systemPrompt,
// Set temperature (default to 0)
// Gemini 3.0 recommends 1.0
// Gemini 3 recommends 1.0
temperature: info.temperature ?? 1,
}
+129 -15
View File
@@ -1,5 +1,6 @@
import { Anthropic, APIError as AnthropicAPIError } from "@anthropic-ai/sdk"
import { liteLlmDefaultModelId, liteLlmModelInfoSaneDefaults, ModelInfo } from "@shared/api"
import OpenAI, { APIError, OpenAIError } from "openai"
import OpenAI, { APIError as OpenAIAPIError, OpenAIError } from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { OcaAuthService } from "@/services/auth/oca/OcaAuthService"
import {
@@ -16,10 +17,12 @@ import { ApiFormat } from "@/shared/proto/index.cline"
import { Logger } from "@/shared/services/Logger"
import { ApiHandler, type CommonApiHandlerOptions } from ".."
import { withRetry } from "../retry"
import { sanitizeAnthropicMessages } from "../transform/anthropic-format"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { convertToOpenAIResponsesInput } from "../transform/openai-response-format"
import { ApiStream } from "../transform/stream"
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
import { convertOpenAIToolsToAnthropicTools, handleAnthropicMessagesApiStreamResponse } from "../utils/messages_api_support"
import { handleResponsesApiStreamResponse } from "../utils/responses_api_support"
export interface OcaHandlerOptions extends CommonApiHandlerOptions {
@@ -35,14 +38,15 @@ export interface OcaHandlerOptions extends CommonApiHandlerOptions {
export class OcaHandler implements ApiHandler {
protected options: OcaHandlerOptions
protected client: OpenAI | undefined
protected openAIClient: OpenAI | undefined
protected anthropicClient: Anthropic | undefined
protected externalHeaders: Record<string, string> = {}
constructor(options: OcaHandlerOptions) {
this.options = options
}
protected initializeClient(options: OcaHandlerOptions): OpenAI {
protected initializeOpenAIClient(options: OcaHandlerOptions): OpenAI {
const externalHeaders = buildExternalBasicHeaders()
return new (class OCIOpenAI extends OpenAI {
protected override async prepareOptions(opts: any): Promise<void> {
@@ -63,7 +67,7 @@ export class OcaHandler implements ApiHandler {
error: Object | undefined,
message: string | undefined,
headers: any | undefined,
): APIError {
): OpenAIAPIError {
interface OciError {
code?: string
message?: string
@@ -94,23 +98,89 @@ export class OcaHandler implements ApiHandler {
})
}
protected ensureClient(): OpenAI {
if (!this.client) {
protected initializeAnthropicClient(options: OcaHandlerOptions): Anthropic {
const externalHeaders = buildExternalBasicHeaders()
return new (class OCIAnthropic extends Anthropic {
protected override async prepareOptions(opts: any): Promise<void> {
const token = await OcaAuthService.getInstance().getAuthToken()
if (!token) {
throw new OpenAIError("Unable to handle auth, Oracle Code Assist (OCA) access token is not available")
}
opts.headers ??= {}
// OCA Headers
const ociHeaders = await createOcaHeaders(token, options.taskId!)
opts.headers = { ...opts.headers, ...externalHeaders, ...ociHeaders }
Logger.log(`Making request with customer opc-request-id: ${opts.headers?.["opc-request-id"]}`)
return super.prepareOptions(opts)
}
protected override makeStatusError(
status: number | undefined,
error: Object | undefined,
message: string | undefined,
headers: any | undefined,
): AnthropicAPIError {
interface OciError {
code?: string
message?: string
}
let ociErrorMessage = message
if (typeof error === "object" && error !== null) {
try {
ociErrorMessage = JSON.stringify(error)
const ociErr = error as OciError
if (ociErr.code !== undefined && ociErr.message !== undefined) {
ociErrorMessage = `${ociErr.code}: ${ociErr.message}`
}
} catch {}
}
const opcRequestId = headers?.[OCI_HEADER_OPC_REQUEST_ID]
if (opcRequestId) {
ociErrorMessage += `\n(${OCI_HEADER_OPC_REQUEST_ID}: ${opcRequestId})`
}
const statusCode = typeof status === "number" ? status : 500
return super.makeStatusError(statusCode, error ?? {}, ociErrorMessage, headers)
}
})({
baseURL:
options.ocaBaseUrl ||
(options.ocaMode === "internal" ? DEFAULT_INTERNAL_OCA_BASE_URL : DEFAULT_EXTERNAL_OCA_BASE_URL),
apiKey: "noop",
fetch, // Use configured fetch with proxy support
})
}
protected ensureOpenAIClient(): OpenAI {
if (!this.openAIClient) {
if (!this.options.ocaModelId) {
throw new Error("Oracle Code Assist (OCA) model is not selected")
}
try {
this.client = this.initializeClient(this.options)
this.openAIClient = this.initializeOpenAIClient(this.options)
} catch (error) {
throw new Error(`Error creating Oracle Code Assist (OCA) client: ${error.message}`)
}
}
return this.client
return this.openAIClient
}
protected ensureAnthropicClient(): Anthropic {
if (!this.anthropicClient) {
if (!this.options.ocaModelId) {
throw new Error("Oracle Code Assist (OCA) model is not selected")
}
try {
this.anthropicClient = this.initializeAnthropicClient(this.options)
} catch (error) {
throw new Error(`Error creating Oracle Code Assist (OCA) client: ${error.message}`)
}
}
return this.anthropicClient
}
async getApiCosts(prompt_tokens: number, completion_tokens: number): Promise<number | undefined> {
// Reference: https://github.com/BerriAI/litellm/blob/122ee634f434014267af104814022af1d9a0882f/litellm/proxy/spend_tracking/spend_management_endpoints.py#L1473
const client = this.ensureClient()
const client = this.ensureOpenAIClient()
const modelId = this.options.ocaModelId || liteLlmDefaultModelId
const token = await OcaAuthService.getInstance().getAuthToken()
if (!token) {
@@ -163,13 +233,15 @@ export class OcaHandler implements ApiHandler {
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: OpenAITool[]): ApiStream {
if (this.options.ocaModelInfo?.apiFormat == ApiFormat.OPENAI_RESPONSES) {
yield* this.createMessageResponsesApi(systemPrompt, messages, tools)
} else if (this.options.ocaModelInfo?.apiFormat == ApiFormat.ANTHROPIC_CHAT) {
yield* this.createMessageMessagesApi(systemPrompt, messages, tools)
} else {
yield* this.createMessageChatApi(systemPrompt, messages, tools)
}
}
async *createMessageChatApi(systemPrompt: string, messages: ClineStorageMessage[], tools?: OpenAITool[]): ApiStream {
const client = this.ensureClient()
const client = this.ensureOpenAIClient()
const formattedMessages = convertToOpenAiMessages(messages)
const systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = {
role: "system",
@@ -306,8 +378,8 @@ export class OcaHandler implements ApiHandler {
}
async *createMessageResponsesApi(systemPrompt: string, messages: ClineStorageMessage[], tools?: OpenAITool[]): ApiStream {
const client = this.ensureClient()
const inputMessages = convertToOpenAIResponsesInput(messages).input
const client = this.ensureOpenAIClient()
const inputMessages = convertToOpenAIResponsesInput(messages, { usePreviousResponseId: false }).input
// Convert messages to Responses API input format
const input: OpenAI.Responses.ResponseInputItem[] = [{ role: "system", content: systemPrompt }, ...inputMessages]
@@ -329,14 +401,56 @@ export class OcaHandler implements ApiHandler {
tools: responseTools,
}
if (this.options.ocaModelInfo && this.options.ocaModelInfo.supportsReasoning) {
responsesParams["reasoning"] = { effort: this.options.ocaReasoningEffort as any, summary: "auto" }
const ocaModelInfo = this.options.ocaModelInfo
if (!ocaModelInfo) {
throw new Error("Oracle Code Assist (OCA) model info is required for Responses API")
}
if (ocaModelInfo.supportsReasoning) {
responsesParams.reasoning = { effort: this.options.ocaReasoningEffort as any, summary: "auto" }
}
// Create the response using Responses API
const stream = await client.responses.create(responsesParams)
yield* handleResponsesApiStreamResponse(stream, this.options.ocaModelInfo!, this.calculateCost.bind(this))
yield* handleResponsesApiStreamResponse(stream, ocaModelInfo, this.calculateCost.bind(this))
}
async *createMessageMessagesApi(systemPrompt: string, messages: ClineStorageMessage[], tools?: OpenAITool[]): ApiStream {
const client = this.ensureAnthropicClient()
const modelId = this.options.ocaModelId || liteLlmDefaultModelId
const budgetTokens = this.options.thinkingBudgetTokens || 0
const reasoningOn = this.options.ocaModelInfo?.supportsReasoning && budgetTokens !== 0
let temperature: number | undefined = this.options.ocaModelInfo?.temperature ?? 0
const maxTokens: number | undefined = this.options.ocaModelInfo?.maxTokens || 8192
if (reasoningOn) {
temperature = 0
}
const anthropicTools = convertOpenAIToolsToAnthropicTools(tools)
const anthropicMessages = sanitizeAnthropicMessages(messages, this.options.ocaUsePromptCache ?? false)
const stream = await client.messages.create({
model: modelId,
max_tokens: maxTokens,
temperature: reasoningOn ? undefined : temperature,
system: [
{
text: systemPrompt,
type: "text",
cache_control: this.options.ocaUsePromptCache ? { type: "ephemeral" } : undefined,
},
],
messages: anthropicMessages,
stream: true,
tools: anthropicTools,
thinking: reasoningOn ? { type: "enabled", budget_tokens: budgetTokens } : undefined,
})
yield* handleAnthropicMessagesApiStreamResponse(stream)
}
getModel() {
+260 -8
View File
@@ -3,11 +3,16 @@ import { normalizeOpenaiReasoningEffort } from "@shared/storage/types"
import OpenAI from "openai"
import type { ChatCompletionTool } from "openai/resources/chat/completions"
import * as os from "os"
import { MessageEvent as UndiciMessageEvent, WebSocket as UndiciWebSocket } from "undici"
import { v7 as uuidv7 } from "uuid"
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
import { featureFlagsService } from "@/services/feature-flags"
import { ClineStorageMessage } from "@/shared/messages/content"
import { fetch } from "@/shared/net"
import { ApiFormat } from "@/shared/proto/cline/models"
import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
import { Logger } from "@/shared/services/Logger"
import { ApiHandler, CommonApiHandlerOptions } from "../"
import { convertToOpenAIResponsesInput } from "../transform/openai-response-format"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
@@ -17,6 +22,7 @@ import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
* Routes to chatgpt.com/backend-api/codex
*/
const CODEX_API_BASE_URL = "https://chatgpt.com/backend-api/codex"
const CODEX_RESPONSES_WEBSOCKET_URL = "wss://chatgpt.com/backend-api/codex/responses"
interface OpenAiCodexHandlerOptions extends CommonApiHandlerOptions {
reasoningEffort?: string
@@ -36,6 +42,8 @@ interface OpenAiCodexHandlerOptions extends CommonApiHandlerOptions {
export class OpenAiCodexHandler implements ApiHandler {
private options: OpenAiCodexHandlerOptions
private client?: OpenAI
private responsesWs: UndiciWebSocket | undefined
private websocketRequestInFlight = false
// Session ID for the Codex API (persists for the lifetime of the handler)
private readonly sessionId: string
// Abort controller for cancelling ongoing requests
@@ -49,7 +57,7 @@ export class OpenAiCodexHandler implements ApiHandler {
this.sessionId = uuidv7()
}
private normalizeUsage(usage: any, model: { id: string; info: ModelInfo }): ApiStreamUsageChunk | undefined {
private normalizeUsage(usage: any, _model: { id: string; info: ModelInfo }): ApiStreamUsageChunk | undefined {
if (!usage) {
return undefined
}
@@ -101,17 +109,18 @@ export class OpenAiCodexHandler implements ApiHandler {
if (!accessToken) {
throw new Error("Not authenticated with OpenAI Codex. Please sign in using the OpenAI Codex OAuth flow in settings.")
}
// Format conversation for Responses API
const formattedInput = convertToOpenAIResponsesInput(messages).input
const useWebsocketMode = this.useWebsocketMode(model.info.apiFormat)
const { input, previousResponseId } = convertToOpenAIResponsesInput(messages, { usePreviousResponseId: useWebsocketMode })
const usePreviousResponseId = useWebsocketMode && !!previousResponseId
// Build request body
const requestBody = this.buildRequestBody(model, formattedInput, systemPrompt, tools)
const requestBody = this.buildRequestBody(model, input, systemPrompt, tools, previousResponseId)
const fallbackRequestBody = this.buildRequestBody(model, input, systemPrompt, tools)
// Make the request with retry on auth failure
for (let attempt = 0; attempt < 2; attempt++) {
try {
yield* this.executeRequest(requestBody, model, accessToken)
yield* this.executeRequest(requestBody, fallbackRequestBody, model, accessToken, usePreviousResponseId)
return
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
@@ -133,11 +142,19 @@ export class OpenAiCodexHandler implements ApiHandler {
}
}
private useWebsocketMode(apiFormat?: ApiFormat): boolean {
if (featureFlagsService.getBooleanFlagEnabled(FeatureFlag.OPENAI_RESPONSES_WEBSOCKET_MODE)) {
return apiFormat === ApiFormat.OPENAI_RESPONSES_WEBSOCKET_MODE
}
return false
}
private buildRequestBody(
model: { id: string; info: ModelInfo },
formattedInput: any,
systemPrompt: string,
tools?: ChatCompletionTool[],
previousResponseId?: string,
): any {
// Determine reasoning effort
const reasoningEffort = normalizeOpenaiReasoningEffort(this.options.reasoningEffort)
@@ -147,8 +164,9 @@ export class OpenAiCodexHandler implements ApiHandler {
model: model.id,
input: formattedInput,
stream: true,
store: false,
store: !previousResponseId,
instructions: systemPrompt,
...(previousResponseId ? { previous_response_id: previousResponseId } : {}),
...(includeReasoning ? { include: ["reasoning.encrypted_content"] } : {}),
...(includeReasoning
? {
@@ -177,7 +195,13 @@ export class OpenAiCodexHandler implements ApiHandler {
return body
}
private async *executeRequest(requestBody: any, model: { id: string; info: ModelInfo }, accessToken: string): ApiStream {
private async *executeRequest(
requestBody: any,
fallbackRequestBody: any,
model: { id: string; info: ModelInfo },
accessToken: string,
useWebsocketMode: boolean,
): ApiStream {
// Create AbortController for cancellation
this.abortController = new AbortController()
@@ -194,6 +218,16 @@ export class OpenAiCodexHandler implements ApiHandler {
...buildExternalBasicHeaders(),
}
if (useWebsocketMode) {
try {
yield* this.createResponseStreamWebsocket(requestBody, fallbackRequestBody, accessToken, codexHeaders, model)
return
} catch (error) {
Logger.error("OpenAI Codex websocket mode failed, falling back to HTTP Responses API:", error)
this.closeResponsesWebsocket()
}
}
// Try using OpenAI SDK first
try {
const client =
@@ -232,6 +266,223 @@ export class OpenAiCodexHandler implements ApiHandler {
}
}
private async *createResponseStreamWebsocket(
primaryParams: OpenAI.Responses.ResponseCreateParamsStreaming,
fallbackParams: OpenAI.Responses.ResponseCreateParamsStreaming,
accessToken: string,
codexHeaders: Record<string, string>,
model: { id: string; info: ModelInfo },
): ApiStream {
try {
for await (const event of this.createResponseEventsViaWebsocket(primaryParams, accessToken, codexHeaders)) {
if (this.abortController?.signal.aborted) {
return
}
yield* this.processEvent(event, model)
}
} catch (error) {
if (this.shouldRetryWebsocketWithFullContext(error, !!primaryParams.previous_response_id)) {
Logger.log(
"Retrying Codex websocket response with full context after previous_response_not_found or socket reset",
)
this.closeResponsesWebsocket()
for await (const event of this.createResponseEventsViaWebsocket(fallbackParams, accessToken, codexHeaders)) {
if (this.abortController?.signal.aborted) {
return
}
yield* this.processEvent(event, model)
}
return
}
throw error
}
}
private shouldRetryWebsocketWithFullContext(error: unknown, hadPreviousResponseId: boolean): boolean {
const errorCode =
typeof error === "object" && error && "code" in error && typeof (error as { code: unknown }).code === "string"
? (error as { code: string }).code
: undefined
if (hadPreviousResponseId && errorCode === "previous_response_not_found") {
return true
}
if (errorCode === "websocket_closed" || errorCode === "websocket_error") {
return true
}
return false
}
private async ensureResponsesWebsocket(accessToken: string, codexHeaders: Record<string, string>): Promise<UndiciWebSocket> {
if (this.responsesWs && this.responsesWs.readyState === UndiciWebSocket.OPEN) {
return this.responsesWs
}
this.closeResponsesWebsocket()
const ws = new UndiciWebSocket(CODEX_RESPONSES_WEBSOCKET_URL, {
headers: {
Authorization: `Bearer ${accessToken}`,
"OpenAI-Beta": "responses_websockets=2026-02-06",
...codexHeaders,
},
})
await new Promise<void>((resolve, reject) => {
const cleanup = () => {
ws.removeEventListener("open", handleOpen)
ws.removeEventListener("error", handleError)
ws.removeEventListener("close", handleClose)
}
const handleOpen = () => {
cleanup()
resolve()
}
const handleError = () => {
cleanup()
reject(new Error("Failed to open Codex Responses websocket"))
}
const handleClose = () => {
cleanup()
reject(new Error("Codex Responses websocket closed before opening"))
}
ws.addEventListener("open", handleOpen)
ws.addEventListener("error", handleError)
ws.addEventListener("close", handleClose)
})
this.responsesWs = ws
return ws
}
private closeResponsesWebsocket() {
if (this.responsesWs) {
try {
this.responsesWs.close()
} catch {}
this.responsesWs = undefined
}
}
private async *createResponseEventsViaWebsocket(
params: OpenAI.Responses.ResponseCreateParamsStreaming,
accessToken: string,
codexHeaders: Record<string, string>,
): AsyncGenerator<OpenAI.Responses.ResponseStreamEvent> {
if (this.websocketRequestInFlight) {
const error: Error & { code?: string } = new Error("Websocket response.create is already in progress")
error.code = "websocket_concurrency_limit"
throw error
}
const ws = await this.ensureResponsesWebsocket(accessToken, codexHeaders)
this.websocketRequestInFlight = true
const eventQueue: OpenAI.Responses.ResponseStreamEvent[] = []
let resolver: (() => void) | undefined
let completed = false
let failure: (Error & { code?: string }) | undefined
const wake = () => {
const next = resolver
resolver = undefined
next?.()
}
const handleMessage = (evt: UndiciMessageEvent) => {
try {
let raw = ""
if (typeof evt.data === "string") {
raw = evt.data
} else if (evt.data instanceof ArrayBuffer) {
raw = new TextDecoder().decode(new Uint8Array(evt.data))
} else if (ArrayBuffer.isView(evt.data)) {
raw = new TextDecoder().decode(new Uint8Array(evt.data.buffer, evt.data.byteOffset, evt.data.byteLength))
} else {
raw = String(evt.data)
}
const parsed = JSON.parse(raw)
if (parsed?.type === "error" && parsed?.error) {
const error: Error & { code?: string } = new Error(parsed.error.message || "Codex Responses websocket error")
error.code = parsed.error.code
failure = error
completed = true
wake()
return
}
eventQueue.push(parsed as OpenAI.Responses.ResponseStreamEvent)
if (parsed?.type === "response.completed" || parsed?.type === "response.failed") {
completed = true
}
wake()
} catch (error) {
const parseError: Error & { code?: string } = new Error(
`Failed to parse websocket event: ${error instanceof Error ? error.message : String(error)}`,
)
parseError.code = "websocket_parse_error"
failure = parseError
completed = true
wake()
}
}
const handleError = () => {
const error: Error & { code?: string } = new Error("Codex Responses websocket emitted an error event")
error.code = "websocket_error"
failure = error
completed = true
wake()
}
const handleClose = () => {
if (!completed) {
const error: Error & { code?: string } = new Error("Codex Responses websocket closed during response stream")
error.code = "websocket_closed"
failure = error
completed = true
wake()
}
}
ws.addEventListener("message", handleMessage)
ws.addEventListener("error", handleError)
ws.addEventListener("close", handleClose)
try {
ws.send(
JSON.stringify({
type: "response.create",
...params,
}),
)
while (!completed || eventQueue.length > 0) {
if (eventQueue.length === 0) {
await new Promise<void>((resolve) => {
resolver = resolve
})
continue
}
const event = eventQueue.shift()
if (event) {
yield event
}
}
if (failure) {
throw failure
}
} finally {
ws.removeEventListener("message", handleMessage)
ws.removeEventListener("error", handleError)
ws.removeEventListener("close", handleClose)
this.websocketRequestInFlight = false
}
}
private async *makeCodexRequest(requestBody: any, model: { id: string; info: ModelInfo }, accessToken: string): ApiStream {
const url = `${CODEX_API_BASE_URL}/responses`
@@ -465,6 +716,7 @@ export class OpenAiCodexHandler implements ApiHandler {
}
abort(): void {
this.closeResponsesWebsocket()
this.abortController?.abort()
}
+349 -35
View File
@@ -8,10 +8,18 @@ import {
import { normalizeOpenaiReasoningEffort } from "@shared/storage/types"
import { calculateApiCostOpenAI } from "@utils/cost"
import OpenAI from "openai"
import type { ChatCompletionReasoningEffort, ChatCompletionTool } from "openai/resources/chat/completions"
import type {
ChatCompletionFunctionTool,
ChatCompletionReasoningEffort,
ChatCompletionTool,
} from "openai/resources/chat/completions"
import { MessageEvent as UndiciMessageEvent, WebSocket as UndiciWebSocket } from "undici"
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
import { featureFlagsService } from "@/services/feature-flags"
import { ClineStorageMessage } from "@/shared/messages/content"
import { createOpenAIClient } from "@/shared/net"
import { ApiFormat } from "@/shared/proto/cline/models"
import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
import { Logger } from "@/shared/services/Logger"
import { isGPT5ModelFamily } from "@/utils/model-utils"
import { ApiHandler, CommonApiHandlerOptions } from "../"
@@ -26,12 +34,16 @@ interface OpenAiNativeHandlerOptions extends CommonApiHandlerOptions {
reasoningEffort?: string
thinkingBudgetTokens?: number
apiModelId?: string
store?: boolean
openAiNativeUseResponsesWebsocket?: boolean
}
export class OpenAiNativeHandler implements ApiHandler {
private options: OpenAiNativeHandlerOptions
private client: OpenAI | undefined
private responsesWs: UndiciWebSocket | undefined
private responsesWsReadyPromise: Promise<UndiciWebSocket> | undefined
private websocketRequestInFlight = false
private abortController?: AbortController
constructor(options: OpenAiNativeHandlerOptions) {
this.options = options
@@ -73,7 +85,8 @@ export class OpenAiNativeHandler implements ApiHandler {
@withRetry()
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: ChatCompletionTool[]): ApiStream {
// Responses API requires tool format to be set to OPENAI_RESPONSES with native tools calling enabled
if (this.getModel()?.info?.apiFormat === ApiFormat.OPENAI_RESPONSES) {
const apiFormat = this.getModel()?.info?.apiFormat
if (apiFormat === ApiFormat.OPENAI_RESPONSES || apiFormat === ApiFormat.OPENAI_RESPONSES_WEBSOCKET_MODE) {
if (!tools?.length) {
throw new Error("Native Tool Call must be enabled in your setting for OpenAI Responses API")
}
@@ -91,13 +104,17 @@ export class OpenAiNativeHandler implements ApiHandler {
const client = this.ensureClient()
const model = this.getModel()
const toolCallProcessor = new ToolCallProcessor()
this.abortController = new AbortController()
// Handle o1 models separately as they don't support streaming
if (model.info.supportsStreaming === false) {
const response = await client.chat.completions.create({
model: model.id,
messages: [{ role: "user", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
})
const response = await client.chat.completions.create(
{
model: model.id,
messages: [{ role: "user", content: systemPrompt }, ...convertToOpenAiMessages(messages, "openai-native")],
},
{ signal: this.abortController?.signal },
)
yield {
type: "text",
text: response.choices[0]?.message.content || "",
@@ -115,7 +132,7 @@ export class OpenAiNativeHandler implements ApiHandler {
const stream = await client.chat.completions.create({
model: model.id,
messages: [{ role: systemRole, content: systemPrompt }, ...convertToOpenAiMessages(messages)],
messages: [{ role: systemRole, content: systemPrompt }, ...convertToOpenAiMessages(messages, "openai-native")],
stream: true,
stream_options: { include_usage: true },
reasoning_effort: reasoningEffort,
@@ -152,26 +169,79 @@ export class OpenAiNativeHandler implements ApiHandler {
messages: ClineStorageMessage[],
tools: ChatCompletionTool[],
): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
const usePreviousResponseId = this.useWebsocketMode(model.info.apiFormat)
// Convert messages to Responses API input format
const { input, previousResponseId } = convertToOpenAIResponsesInput(messages)
// Warm websocket connection early in websocket mode so the first response.create avoids handshake latency.
if (usePreviousResponseId) {
this.preconnectResponsesWebsocket()
}
// Convert ChatCompletion tools to Responses API format if provided
const responseTools = tools
?.filter((tool) => tool?.type === "function")
.map((tool: any) => ({
const { input, previousResponseId } = convertToOpenAIResponsesInput(messages, { usePreviousResponseId })
const responseTools = this.mapResponseTools(tools)
this.abortController = new AbortController()
const params = this.buildResponseCreateParams({
modelId: model.id,
systemPrompt,
input,
previousResponseId,
tools: responseTools,
})
const fallbackParams = this.buildResponseCreateParams({
modelId: model.id,
systemPrompt,
input,
tools: responseTools,
})
if (usePreviousResponseId && previousResponseId) {
try {
yield* this.createResponseStreamWebsocket(model.info, params, fallbackParams)
return
} catch (error) {
Logger.error("OpenAI websocket mode failed, falling back to HTTP Responses API:", error)
this.closeResponsesWebsocket()
}
}
yield* this.createResponseStreamHttp(model.info, params)
}
private preconnectResponsesWebsocket(): void {
void this.ensureResponsesWebsocket().catch((error) => {
Logger.debug("OpenAI websocket preconnect failed:", error)
this.closeResponsesWebsocket()
})
}
private useWebsocketMode(apiFormat?: ApiFormat): boolean {
if (featureFlagsService.getBooleanFlagEnabled(FeatureFlag.OPENAI_RESPONSES_WEBSOCKET_MODE)) {
return apiFormat === ApiFormat.OPENAI_RESPONSES_WEBSOCKET_MODE
}
return false
}
private mapResponseTools(tools: ChatCompletionTool[]): OpenAI.Responses.Tool[] {
return tools
?.filter((tool): tool is ChatCompletionFunctionTool => tool?.type === "function")
.map((tool) => ({
type: "function" as const,
name: tool.function.name,
description: tool.function.description,
parameters: tool.function.parameters,
strict: tool.function.strict ?? true, // Responses API defaults to strict mode
parameters: tool.function.parameters ?? null,
strict: tool.function.strict ?? true,
}))
}
Logger.debug(`OpenAI Responses Input: ${JSON.stringify(input)}`)
// Create the response using Responses API
private buildResponseCreateParams(args: {
modelId: string
systemPrompt: string
input: OpenAI.Responses.ResponseInput
tools: OpenAI.Responses.Tool[]
previousResponseId?: string
}): OpenAI.Responses.ResponseCreateParamsStreaming {
const requestedEffort = normalizeOpenaiReasoningEffort(this.options.reasoningEffort)
const reasoning: { effort: ChatCompletionReasoningEffort; summary: "auto" } | undefined =
requestedEffort === "none"
@@ -181,25 +251,261 @@ export class OpenAiNativeHandler implements ApiHandler {
summary: "auto",
}
const stream = await client.responses.create({
model: model.id,
instructions: systemPrompt,
input,
return {
model: args.modelId,
instructions: args.systemPrompt,
input: args.input,
stream: true,
tools: responseTools,
store: this.options.store ?? false,
...(previousResponseId ? { previous_response_id: previousResponseId } : {}),
tools: args.tools,
store: !args.previousResponseId, // Do not use store when websocket mode is enabled.
...(args.previousResponseId ? { previous_response_id: args.previousResponseId } : {}),
...(reasoning ? { reasoning } : {}),
// include: ["reasoning.encrypted_content"],
}
}
private async *createResponseStreamHttp(
modelInfo: ModelInfo,
params: OpenAI.Responses.ResponseCreateParamsStreaming,
): ApiStream {
const client = this.ensureClient()
Logger.debug(`OpenAI Responses Input (HTTP): ${JSON.stringify(params.input)}`)
const stream = await client.responses.create(params, { signal: this.abortController?.signal })
yield* this.processResponsesEvents(stream, modelInfo)
}
private async *createResponseStreamWebsocket(
modelInfo: ModelInfo,
primaryParams: OpenAI.Responses.ResponseCreateParamsStreaming,
fallbackParams: OpenAI.Responses.ResponseCreateParamsStreaming,
): ApiStream {
Logger.debug(`OpenAI Responses Input (WebSocket): ${JSON.stringify(primaryParams.input)}`)
try {
yield* this.processResponsesEvents(this.createResponseEventsViaWebsocket(primaryParams), modelInfo)
} catch (error) {
if (this.shouldRetryWebsocketWithFullContext(error, !!primaryParams.previous_response_id)) {
Logger.log("Retrying websocket response with full context after previous_response_not_found or socket reset")
this.closeResponsesWebsocket()
yield* this.processResponsesEvents(this.createResponseEventsViaWebsocket(fallbackParams), modelInfo)
return
}
throw error
}
}
private shouldRetryWebsocketWithFullContext(error: unknown, hadPreviousResponseId: boolean): boolean {
const errorCode =
typeof error === "object" && error && "code" in error && typeof (error as { code: unknown }).code === "string"
? (error as { code: string }).code
: undefined
if (hadPreviousResponseId && errorCode === "previous_response_not_found") {
return true
}
if (errorCode === "websocket_closed" || errorCode === "websocket_error") {
return true
}
return false
}
private async ensureResponsesWebsocket(): Promise<UndiciWebSocket> {
if (this.responsesWs && this.responsesWs.readyState === UndiciWebSocket.OPEN) {
return this.responsesWs
}
if (this.responsesWsReadyPromise) {
return this.responsesWsReadyPromise
}
this.closeResponsesWebsocket()
if (!this.options.openAiNativeApiKey) {
throw new Error("OpenAI API key is required")
}
const ws = new UndiciWebSocket("wss://api.openai.com/v1/responses", {
headers: {
Authorization: `Bearer ${this.options.openAiNativeApiKey}`,
"OpenAI-Beta": "responses_websockets=2026-02-06",
...buildExternalBasicHeaders(),
},
})
this.responsesWs = ws
const readyPromise = new Promise<UndiciWebSocket>((resolve, reject) => {
const cleanup = () => {
ws.removeEventListener("open", handleOpen)
ws.removeEventListener("error", handleError)
ws.removeEventListener("close", handleClose)
}
const handleOpen = () => {
cleanup()
resolve(ws)
}
const handleError = () => {
cleanup()
reject(new Error("Failed to open Responses websocket"))
}
const handleClose = () => {
cleanup()
reject(new Error("Responses websocket closed before opening"))
}
ws.addEventListener("open", handleOpen)
ws.addEventListener("error", handleError)
ws.addEventListener("close", handleClose)
})
this.responsesWsReadyPromise = readyPromise
try {
return await readyPromise
} catch (error) {
if (this.responsesWs === ws) {
this.responsesWs = undefined
}
throw error
} finally {
if (this.responsesWsReadyPromise === readyPromise) {
this.responsesWsReadyPromise = undefined
}
}
}
private closeResponsesWebsocket() {
this.responsesWsReadyPromise = undefined
if (this.responsesWs) {
try {
this.responsesWs.close()
} catch {}
this.responsesWs = undefined
}
}
private async *createResponseEventsViaWebsocket(
params: OpenAI.Responses.ResponseCreateParamsStreaming,
): AsyncGenerator<OpenAI.Responses.ResponseStreamEvent> {
if (this.websocketRequestInFlight) {
const error: Error & { code?: string } = new Error("Websocket response.create is already in progress")
error.code = "websocket_concurrency_limit"
throw error
}
const ws = await this.ensureResponsesWebsocket()
this.websocketRequestInFlight = true
const eventQueue: OpenAI.Responses.ResponseStreamEvent[] = []
let resolver: (() => void) | undefined
let completed = false
let failure: (Error & { code?: string }) | undefined
const wake = () => {
const next = resolver
resolver = undefined
next?.()
}
const handleMessage = (evt: UndiciMessageEvent) => {
try {
let raw = ""
if (typeof evt.data === "string") {
raw = evt.data
} else if (evt.data instanceof ArrayBuffer) {
raw = new TextDecoder().decode(new Uint8Array(evt.data))
} else if (ArrayBuffer.isView(evt.data)) {
raw = new TextDecoder().decode(new Uint8Array(evt.data.buffer, evt.data.byteOffset, evt.data.byteLength))
} else {
raw = String(evt.data)
}
const parsed = JSON.parse(raw)
if (parsed?.type === "error" && parsed?.error) {
const error: Error & { code?: string } = new Error(parsed.error.message || "Responses websocket error")
error.code = parsed.error.code
failure = error
completed = true
wake()
return
}
eventQueue.push(parsed as OpenAI.Responses.ResponseStreamEvent)
if (parsed?.type === "response.completed" || parsed?.type === "response.failed") {
completed = true
}
wake()
} catch (error) {
const parseError: Error & { code?: string } = new Error(
`Failed to parse websocket event: ${error instanceof Error ? error.message : String(error)}`,
)
parseError.code = "websocket_parse_error"
failure = parseError
completed = true
wake()
}
}
const handleError = () => {
const error: Error & { code?: string } = new Error("Responses websocket emitted an error event")
error.code = "websocket_error"
failure = error
completed = true
wake()
}
const handleClose = () => {
if (!completed) {
const error: Error & { code?: string } = new Error("Responses websocket closed during response stream")
error.code = "websocket_closed"
failure = error
completed = true
wake()
}
}
ws.addEventListener("message", handleMessage)
ws.addEventListener("error", handleError)
ws.addEventListener("close", handleClose)
try {
ws.send(
JSON.stringify({
type: "response.create",
...params,
}),
)
while (!completed || eventQueue.length > 0) {
if (eventQueue.length === 0) {
await new Promise<void>((resolve) => {
resolver = resolve
})
continue
}
const event = eventQueue.shift()
if (event) {
yield event
}
}
if (failure) {
throw failure
}
} finally {
ws.removeEventListener("message", handleMessage)
ws.removeEventListener("error", handleError)
ws.removeEventListener("close", handleClose)
this.websocketRequestInFlight = false
}
}
private async *processResponsesEvents(
stream: AsyncIterable<OpenAI.Responses.ResponseStreamEvent>,
modelInfo: ModelInfo,
): ApiStream {
const functionCallByItemId = new Map<string, { call_id?: string; name?: string; id?: string }>()
// Process the response stream
for await (const chunk of stream) {
Logger.debug(`OpenAI Responses Chunk: ${JSON.stringify(chunk)}`)
// Handle different event types from Responses API
if (chunk.type === "response.output_item.added") {
const item = chunk.item
if (item.type === "function_call" && item.id) {
@@ -277,7 +583,6 @@ export class OpenAiNativeHandler implements ApiHandler {
}
}
if (chunk.type === "response.output_text.delta") {
// Handle text content deltas
if (chunk.delta) {
yield {
id: chunk.item_id,
@@ -287,7 +592,6 @@ export class OpenAiNativeHandler implements ApiHandler {
}
}
if (chunk.type === "response.reasoning_text.delta") {
// Handle reasoning content deltas
if (chunk.delta) {
yield {
id: chunk.item_id,
@@ -315,7 +619,6 @@ export class OpenAiNativeHandler implements ApiHandler {
}
}
if (chunk.type === "response.function_call_arguments.done") {
// Handle completed function call
if (chunk.item_id && chunk.name && chunk.arguments) {
const pendingCall = functionCallByItemId.get(chunk.item_id)
const callId = pendingCall?.call_id
@@ -348,7 +651,6 @@ export class OpenAiNativeHandler implements ApiHandler {
}
if (chunk.type === "response.completed" && chunk.response?.usage) {
// Handle usage information when response is complete
const usage = chunk.response.usage
const inputTokens = usage.input_tokens || 0
const outputTokens = usage.output_tokens || 0
@@ -357,7 +659,13 @@ export class OpenAiNativeHandler implements ApiHandler {
const reasoningTokens = usage.output_tokens_details?.reasoning_tokens || 0
const totalTokens = usage.total_tokens || 0
Logger.log(`Total tokens from Responses API usage: ${totalTokens}`)
const totalCost = calculateApiCostOpenAI(model.info, inputTokens, outputTokens + reasoningTokens, cacheWriteTokens, cacheReadTokens)
const totalCost = calculateApiCostOpenAI(
modelInfo,
inputTokens,
outputTokens + reasoningTokens,
cacheWriteTokens,
cacheReadTokens,
)
const nonCachedInputTokens = Math.max(0, inputTokens - cacheReadTokens - cacheWriteTokens)
yield {
type: "usage",
@@ -373,6 +681,12 @@ export class OpenAiNativeHandler implements ApiHandler {
}
}
abort(): void {
this.closeResponsesWebsocket()
this.abortController?.abort()
this.abortController = undefined
}
getModel(): { id: OpenAiNativeModelId; info: OpenAiCompatibleModelInfo } {
const modelId = this.options.apiModelId
if (modelId && modelId in openAiNativeModels) {
+7 -1
View File
@@ -122,7 +122,12 @@ export class OpenRouterHandler implements ApiHandler {
// Reasoning tokens are returned separately from the content
// Skip reasoning content for Grok 4 models since it only displays "thinking" without providing useful information
if ("reasoning" in delta && delta.reasoning && !shouldSkipReasoningForModel(this.options.openRouterModelId)) {
if (
delta &&
"reasoning" in delta &&
delta.reasoning &&
!shouldSkipReasoningForModel(this.options.openRouterModelId)
) {
yield {
type: "reasoning",
reasoning: typeof delta.reasoning === "string" ? delta.reasoning : JSON.stringify(delta.reasoning),
@@ -132,6 +137,7 @@ export class OpenRouterHandler implements ApiHandler {
// OpenRouter passes reasoning details that we can pass back unmodified in api requests to preserve reasoning traces for model
// See: https://openrouter.ai/docs/use-cases/reasoning-tokens#preserving-reasoning-blocks
if (
delta &&
"reasoning_details" in delta &&
delta.reasoning_details &&
// @ts-expect-error-next-line
+2 -2
View File
@@ -52,7 +52,7 @@ export class SambanovaHandler implements ApiHandler {
const modelId = model.id.toLowerCase()
if (modelId.includes("deepseek") || modelId.includes("qwen") || modelId.includes("qwq")) {
if (modelId.includes("deepseek") || modelId.includes("qwen3")) {
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
}
@@ -60,7 +60,7 @@ export class SambanovaHandler implements ApiHandler {
const stream = await client.chat.completions.create({
model: this.getModel().id,
messages: openAiMessages,
temperature: 0,
temperature: model.info.temperature ?? 0,
stream: true,
stream_options: { include_usage: true },
...getOpenAIToolParams(tools),
+13 -12
View File
@@ -372,12 +372,12 @@ export class SapAiCoreHandler implements ApiHandler {
private chunkToString(chunk: any): string {
if (Buffer.isBuffer(chunk)) {
return chunk.toString("utf-8")
} else if (typeof chunk === "string") {
return chunk
} else {
// Handle comma-separated byte values or other array-like formats
return Buffer.from(chunk).toString("utf-8")
}
if (typeof chunk === "string") {
return chunk
}
// Handle comma-separated byte values or other array-like formats
return Buffer.from(chunk).toString("utf-8")
}
private validateCredentials(): void {
@@ -526,7 +526,7 @@ export class SapAiCoreHandler implements ApiHandler {
if (!expiresIn) {
throw new Error("Destination is missing required authTokens with expiresIn")
}
this.destinationExpiresAt = Date.now() + parseInt(expiresIn, 10) * 1000
this.destinationExpiresAt = Date.now() + Number.parseInt(expiresIn, 10) * 1000
}
}
@@ -849,20 +849,21 @@ export class SapAiCoreHandler implements ApiHandler {
if (error.response.status === 404) {
throw new Error(`404 Not Found: ${errorMessage}`)
} else if (error.response.status === 400) {
}
if (error.response.status === 400) {
throw new Error(`400 Bad Request: ${errorMessage}`)
}
throw new Error(`HTTP ${error.response.status}: ${errorMessage}`)
} else if (error.request) {
}
if (error.request) {
// The request was made but no response was received
Logger.error("Error request:", error.request)
throw new Error("No response received from server")
} else {
// Something happened in setting up the request that triggered an Error
Logger.error("Error message:", error.message)
throw new Error(`Error setting up request: ${error.message}`)
}
// Something happened in setting up the request that triggered an Error
Logger.error("Error message:", error.message)
throw new Error(`Error setting up request: ${error.message}`)
}
}
+7 -1
View File
@@ -84,7 +84,12 @@ export class VercelAIGatewayHandler implements ApiHandler {
// Reasoning tokens are returned separately from the content
// Skip reasoning content for models that don't support it (e.g., devstral, grok-4)
if ("reasoning" in delta && delta.reasoning && !shouldSkipReasoningForModel(this.options.openRouterModelId)) {
if (
delta &&
"reasoning" in delta &&
delta.reasoning &&
!shouldSkipReasoningForModel(this.options.openRouterModelId)
) {
yield {
type: "reasoning",
reasoning: typeof delta.reasoning === "string" ? delta.reasoning : JSON.stringify(delta.reasoning),
@@ -93,6 +98,7 @@ export class VercelAIGatewayHandler implements ApiHandler {
// Reasoning details that can be passed back in API requests to preserve reasoning traces
if (
delta &&
"reasoning_details" in delta &&
delta.reasoning_details &&
// @ts-expect-error-next-line
@@ -37,7 +37,7 @@ describe("Tool Call Parsing", () => {
},
]
const result = convertToOpenAiMessages(messages)
const result = convertToOpenAiMessages(messages, "openai-native")
result.should.have.length(1)
const msg = result[0] as any
@@ -64,7 +64,7 @@ describe("Tool Call Parsing", () => {
},
]
const result = convertToOpenAiMessages(messages)
const result = convertToOpenAiMessages(messages, "openai-native")
const msg = result[0] as any
msg.tool_calls[0].id.length.should.be.belowOrEqual(40)
+11 -3
View File
@@ -1,5 +1,6 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { ApiProvider } from "@/shared/api"
import {
ClineAssistantRedactedThinkingBlock,
ClineAssistantThinkingBlock,
@@ -27,20 +28,25 @@ function isOpenAIResponseToolId(callId: string): boolean {
/**
* Transforms a tool ID to a consistent format for OpenAI's Chat Completions API.
* NOTE: We do not want to transform tool IDs for non-OpenAI providers that may have different requirements.
* This function MUST be used for both tool_calls[].id (assistant) and tool_call_id (tool result)
* to ensure they match - otherwise OpenAI will reject the request with:
* "Invalid parameter: 'tool_call_id' of 'xxx' not found in 'tool_calls' of previous message."
*
* @param toolId - The original tool ID from Cline/Anthropic format
* @param provider - The API provider that the OpenAI formatted messages will be sent to
* @returns The transformed ID suitable for OpenAI API
*/
function transformToolCallId(toolId: string): string {
function transformToolCallIdForNativeApi(toolId: string, provider?: ApiProvider): string {
// OpenAI Responses API uses "fc_" prefix with 53 char length
// Convert these to "call_" prefix format for Chat Completions API
if (isOpenAIResponseToolId(toolId)) {
// Use the last 33 chars + "call_" (5 chars) to stay under the 40-char limit.
return `call_${toolId.slice(toolId.length - (MAX_TOOL_CALL_ID_LENGTH - 5))}`
}
if (provider !== "openai-native") {
return toolId
}
// Ensure ID doesn't exceed max length
if (toolId.length > MAX_TOOL_CALL_ID_LENGTH) {
return toolId.slice(0, MAX_TOOL_CALL_ID_LENGTH)
@@ -55,10 +61,12 @@ function transformToolCallId(toolId: string): string {
* into OpenAI's expected message structure, including tool_calls and tool_call_id fields.
*
* @param anthropicMessages - Array of ClineStorageMessage objects to be converted
* @param provider - Optional parameter to indicate the API provider, which may affect ID transformation logic
* @returns Array of OpenAI.Chat.ChatCompletionMessageParam objects
*/
export function convertToOpenAiMessages(
anthropicMessages: Omit<ClineStorageMessage, "modelInfo">[],
provider?: ApiProvider,
): OpenAI.Chat.ChatCompletionMessageParam[] {
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = []
@@ -120,7 +128,7 @@ export function convertToOpenAiMessages(
role: "tool",
// The tool_call_id must match the id used in the assistant's tool_calls array.
// Use the same transformation logic as tool_calls to ensure IDs match.
tool_call_id: transformToolCallId(toolMessage.tool_use_id),
tool_call_id: transformToolCallIdForNativeApi(toolMessage.tool_use_id, provider),
content: content,
})
})
@@ -233,7 +241,7 @@ export function convertToOpenAiMessages(
return {
// Use the same transformation as tool_call_id to ensure IDs match
id: transformToolCallId(toolId),
id: transformToolCallIdForNativeApi(toolId, provider),
type: "function",
function: {
name: toolMessage.name,
@@ -85,7 +85,10 @@ export function convertToOpenAIResponsesInput(
if (options?.usePreviousResponseId) {
for (let i = _messages.length - 1; i >= 0; i--) {
const msg = _messages[i]
if (msg.role === "assistant" && msg.id) {
// Must be less than 24 hours old to be considered for chaining as the previous Id is only valid for 24 hours.
// Set to 23 hours to account for any potential delays in processing.
const isLessThan23HoursOld = msg.ts ? Date.now() - msg.ts < 23 * 60 * 60 * 1000 : false
if (msg.role === "assistant" && msg.id && isLessThan23HoursOld) {
previousResponseId = msg.id
messages = _messages.slice(i + 1)
break
+2 -2
View File
@@ -5,8 +5,8 @@ import {
OPENROUTER_PROVIDER_PREFERENCES,
openRouterClaudeOpus461mModelId,
openRouterClaudeSonnet41mModelId,
openRouterClaudeSonnet461mModelId,
openRouterClaudeSonnet451mModelId,
openRouterClaudeSonnet461mModelId,
} from "@shared/api"
import { normalizeOpenaiReasoningEffort } from "@shared/storage/types"
import { shouldSkipReasoningForModel, supportsReasoningEffortForModel } from "@utils/model-utils"
@@ -160,7 +160,7 @@ export async function createOpenRouterStream(
topP = 0.95
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
}
if (model.id.startsWith("google/gemini-3.0") || model.id === "google/gemini-3.0") {
if (model.id.startsWith("google/gemini-3")) {
// Recommended value from google
temperature = 1.0
}
@@ -4,8 +4,8 @@ import {
ModelInfo,
openRouterClaudeOpus461mModelId,
openRouterClaudeSonnet41mModelId,
openRouterClaudeSonnet461mModelId,
openRouterClaudeSonnet451mModelId,
openRouterClaudeSonnet461mModelId,
} from "@shared/api"
import { normalizeOpenaiReasoningEffort } from "@shared/storage/types"
import { shouldSkipReasoningForModel, supportsReasoningEffortForModel } from "@utils/model-utils"
@@ -100,7 +100,7 @@ export async function createVercelAIGatewayStream(
topP = 0.95
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
}
if (model.id.startsWith("google/gemini-3.0") || model.id === "google/gemini-3.0") {
if (model.id.startsWith("google/gemini-3")) {
// Recommended value from google
temperature = 1.0
}
+150
View File
@@ -0,0 +1,150 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { Tool as AnthropicTool } from "@anthropic-ai/sdk/resources/index"
import { Stream as AnthropicStream } from "@anthropic-ai/sdk/streaming"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { ApiStream } from "../transform/stream"
export async function* handleAnthropicMessagesApiStreamResponse(
stream: AnthropicStream<Anthropic.RawMessageStreamEvent>,
): ApiStream {
const lastStartedToolCall = { id: "", name: "", arguments: "" }
for await (const chunk of stream) {
switch (chunk?.type) {
case "message_start": {
const usage = chunk.message.usage
yield {
type: "usage",
inputTokens: usage.input_tokens || 0,
outputTokens: usage.output_tokens || 0,
cacheWriteTokens: usage.cache_creation_input_tokens || undefined,
cacheReadTokens: usage.cache_read_input_tokens || undefined,
}
break
}
case "message_delta":
yield {
type: "usage",
inputTokens: 0,
outputTokens: chunk.usage.output_tokens || 0,
}
break
case "message_stop":
break
case "content_block_start":
switch (chunk.content_block.type) {
case "thinking":
yield {
type: "reasoning",
reasoning: chunk.content_block.thinking || "",
signature: chunk.content_block.signature,
}
break
case "redacted_thinking":
// Content is encrypted, and we don't want to pass placeholder text back to the API
yield {
type: "reasoning",
reasoning: "[Redacted thinking block]",
redacted_data: chunk.content_block.data,
}
break
case "tool_use":
if (chunk.content_block.id && chunk.content_block.name) {
lastStartedToolCall.id = chunk.content_block.id
lastStartedToolCall.name = chunk.content_block.name
lastStartedToolCall.arguments = ""
}
break
case "text":
if (chunk.index > 0) {
yield {
type: "text",
text: "\n",
}
}
yield {
type: "text",
text: chunk.content_block.text,
}
break
}
break
case "content_block_delta":
switch (chunk.delta.type) {
case "thinking_delta":
yield {
type: "reasoning",
reasoning: chunk.delta.thinking,
}
break
case "signature_delta":
if (chunk.delta.signature) {
yield {
type: "reasoning",
reasoning: "",
signature: chunk.delta.signature,
}
}
break
case "text_delta":
yield {
type: "text",
text: chunk.delta.text,
}
break
case "input_json_delta":
if (lastStartedToolCall.id && lastStartedToolCall.name && chunk.delta.partial_json) {
yield {
type: "tool_calls",
tool_call: {
...lastStartedToolCall,
function: {
...lastStartedToolCall,
id: lastStartedToolCall.id,
name: lastStartedToolCall.name,
arguments: chunk.delta.partial_json,
},
},
}
}
break
}
break
case "content_block_stop":
lastStartedToolCall.id = ""
lastStartedToolCall.name = ""
lastStartedToolCall.arguments = ""
break
}
}
}
export function convertOpenAIToolsToAnthropicTools(tools?: OpenAITool[]): AnthropicTool[] | undefined {
if (!tools?.length) {
return undefined
}
const anthropicTools: AnthropicTool[] = []
for (const tool of tools) {
if (tool?.type !== "function" || !tool.function?.name) {
continue
}
const fn = tool.function
const hasSchemaObject = fn.parameters && typeof fn.parameters === "object"
const inputSchema = hasSchemaObject ? { ...fn.parameters } : {}
if (typeof (inputSchema as { type?: unknown }).type !== "string") {
;(inputSchema as { type: string }).type = "object"
}
anthropicTools.push({
name: fn.name,
description: fn.description || undefined,
input_schema: inputSchema as AnthropicTool["input_schema"],
})
}
return anthropicTools.length > 0 ? anthropicTools : undefined
}
@@ -1,4 +1,4 @@
import { ClineDefaultTool, toolUseNames } from "@shared/tools"
import { ClineDefaultTool, getToolUseNames } from "@shared/tools"
import { nanoid } from "nanoid"
import { AssistantMessageContent, TextStreamContent, ToolParamName, ToolUse, toolParamNames } from "." // Assuming types are defined in index.ts or a similar file
@@ -35,9 +35,9 @@ export function parseAssistantMessageV2(assistantMessage: string): AssistantMess
let currentParamName: ToolParamName | undefined
// Precompute tags for faster lookups
const toolUseOpenTags = new Map<string, ClineDefaultTool>()
const toolUseOpenTags = new Map<string, string>()
const toolParamOpenTags = new Map<string, ToolParamName>()
for (const name of toolUseNames) {
for (const name of getToolUseNames()) {
toolUseOpenTags.set(`<${name}>`, name)
}
for (const name of toolParamNames) {
@@ -173,7 +173,7 @@ export function parseAssistantMessageV2(assistantMessage: string): AssistantMess
// Start the new tool use
currentToolUse = {
type: "tool_use",
name: toolName,
name: toolName as ClineDefaultTool,
params: {},
partial: true, // Assume partial until closing tag is found
call_id: nanoid(8),
@@ -0,0 +1,107 @@
import * as disk from "@core/storage/disk"
import axios from "axios"
import { expect } from "chai"
import fs from "fs/promises"
import { afterEach, beforeEach, describe, it } from "mocha"
import sinon from "sinon"
import { ClineEnv, Environment } from "@/config"
import { getFeatureFlagsService } from "@/services/feature-flags"
import { CLINE_RECOMMENDED_MODELS_FALLBACK } from "@/shared/cline/recommended-models"
import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
import { Logger } from "@/shared/services/Logger"
import { refreshClineRecommendedModels, resetClineRecommendedModelsCacheForTests } from "../refreshClineRecommendedModels"
describe("refreshClineRecommendedModels", () => {
let sandbox: sinon.SinonSandbox
beforeEach(() => {
sandbox = sinon.createSandbox()
resetClineRecommendedModelsCacheForTests()
sandbox.stub(Logger, "log")
sandbox.stub(Logger, "error")
})
afterEach(() => {
resetClineRecommendedModelsCacheForTests()
sandbox.restore()
})
it("returns hardcoded models and skips upstream fetch when rollout flag is off", async () => {
sandbox.stub(getFeatureFlagsService(), "getBooleanFlagEnabled").returns(false)
const axiosGetStub = sandbox.stub(axios, "get")
const result = await refreshClineRecommendedModels()
expect(result).to.deep.equal(CLINE_RECOMMENDED_MODELS_FALLBACK)
expect(axiosGetStub.called).to.equal(false)
})
it("fetches from upstream when rollout flag is on", async () => {
sandbox.stub(getFeatureFlagsService(), "getBooleanFlagEnabled").callsFake((flag) => {
return flag === FeatureFlag.CLINE_RECOMMENDED_MODELS_UPSTREAM
})
sandbox.stub(ClineEnv, "config").returns({
environment: Environment.production,
appBaseUrl: "https://app.cline-mock.bot",
apiBaseUrl: "https://api.cline-mock.bot",
mcpBaseUrl: "https://api.cline-mock.bot/v1/mcp",
})
sandbox.stub(disk, "ensureCacheDirectoryExists").resolves("/tmp")
sandbox.stub(fs, "writeFile").resolves()
const axiosGetStub = sandbox.stub(axios, "get").resolves({
data: {
recommended: [{ id: "anthropic/claude-sonnet-4.6", description: "Remote recommended", tags: ["NEW"] }],
free: [{ id: "z-ai/glm-5", description: "Remote free" }],
},
})
const result = await refreshClineRecommendedModels()
expect(axiosGetStub.calledOnce).to.equal(true)
expect(result).to.deep.equal({
recommended: [
{
id: "anthropic/claude-sonnet-4.6",
name: "anthropic/claude-sonnet-4.6",
description: "Remote recommended",
tags: ["NEW"],
},
],
free: [
{
id: "z-ai/glm-5",
name: "z-ai/glm-5",
description: "Remote free",
tags: [],
},
],
})
})
it("uses hardcoded models when rollout flag is turned off after upstream cache is populated", async () => {
const flagStub = sandbox.stub(getFeatureFlagsService(), "getBooleanFlagEnabled")
flagStub.onFirstCall().returns(true)
flagStub.onSecondCall().returns(false)
sandbox.stub(ClineEnv, "config").returns({
environment: Environment.production,
appBaseUrl: "https://app.cline-mock.bot",
apiBaseUrl: "https://api.cline-mock.bot",
mcpBaseUrl: "https://api.cline-mock.bot/v1/mcp",
})
sandbox.stub(disk, "ensureCacheDirectoryExists").resolves("/tmp")
sandbox.stub(fs, "writeFile").resolves()
const axiosGetStub = sandbox.stub(axios, "get").resolves({
data: {
recommended: [{ id: "google/gemini-3.1-pro-preview", description: "Remote recommended", tags: ["NEW"] }],
free: [{ id: "minimax/minimax-m2.5", description: "Remote free", tags: ["FREE"] }],
},
})
const firstResult = await refreshClineRecommendedModels()
const secondResult = await refreshClineRecommendedModels()
expect(axiosGetStub.calledOnce).to.equal(true)
expect(firstResult).to.not.deep.equal(CLINE_RECOMMENDED_MODELS_FALLBACK)
expect(secondResult).to.deep.equal(CLINE_RECOMMENDED_MODELS_FALLBACK)
})
})
@@ -0,0 +1,152 @@
import { ensureCacheDirectoryExists, GlobalFileNames } from "@core/storage/disk"
import axios from "axios"
import fs from "fs/promises"
import path from "path"
import { ClineEnv } from "@/config"
import { featureFlagsService } from "@/services/feature-flags"
import { CLINE_RECOMMENDED_MODELS_FALLBACK } from "@/shared/cline/recommended-models"
import { getAxiosSettings } from "@/shared/net"
import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
import { Logger } from "@/shared/services/Logger"
export interface ClineRecommendedModelData {
id: string
name: string
description: string
tags: string[]
}
export interface ClineRecommendedModelsData {
recommended: ClineRecommendedModelData[]
free: ClineRecommendedModelData[]
}
const RECOMMENDED_MODELS_CACHE_TTL_MS = 60 * 60 * 1000
let pendingRefresh: Promise<ClineRecommendedModelsData> | null = null
let inMemoryCache: { data: ClineRecommendedModelsData; timestamp: number } | null = null
function getHardcodedRecommendedModels(): ClineRecommendedModelsData {
return CLINE_RECOMMENDED_MODELS_FALLBACK
}
function useUpstreamRecommendedModels(): boolean {
return featureFlagsService.getBooleanFlagEnabled(FeatureFlag.CLINE_RECOMMENDED_MODELS_UPSTREAM)
}
function normalizeRecommendedModel(raw: unknown): ClineRecommendedModelData | null {
if (!raw || typeof raw !== "object") {
return null
}
const data = raw as Record<string, unknown>
if (typeof data.id !== "string" || data.id.length === 0) {
return null
}
return {
id: data.id,
name: typeof data.name === "string" && data.name.length > 0 ? data.name : data.id,
description: typeof data.description === "string" ? data.description : "",
tags: Array.isArray(data.tags) ? data.tags.filter((tag): tag is string => typeof tag === "string") : [],
}
}
function normalizeRecommendedModelsResponse(raw: unknown): ClineRecommendedModelsData | null {
if (!raw || typeof raw !== "object") {
return null
}
const data = raw as Record<string, unknown>
if (
(data.recommended !== undefined && !Array.isArray(data.recommended)) ||
(data.free !== undefined && !Array.isArray(data.free))
) {
return null
}
const recommendedRaw = Array.isArray(data.recommended) ? data.recommended : []
const freeRaw = Array.isArray(data.free) ? data.free : []
const recommended = recommendedRaw
.map((model) => normalizeRecommendedModel(model))
.filter((model): model is ClineRecommendedModelData => model !== null)
const free = freeRaw
.map((model) => normalizeRecommendedModel(model))
.filter((model): model is ClineRecommendedModelData => model !== null)
return { recommended, free }
}
export async function refreshClineRecommendedModels(): Promise<ClineRecommendedModelsData> {
if (!useUpstreamRecommendedModels()) {
return getHardcodedRecommendedModels()
}
if (inMemoryCache && Date.now() - inMemoryCache.timestamp <= RECOMMENDED_MODELS_CACHE_TTL_MS) {
return inMemoryCache.data
}
if (pendingRefresh) {
return pendingRefresh
}
pendingRefresh = (async () => {
try {
return await fetchAndCacheClineRecommendedModels()
} finally {
pendingRefresh = null
}
})()
return pendingRefresh
}
export function resetClineRecommendedModelsCacheForTests(): void {
pendingRefresh = null
inMemoryCache = null
}
async function fetchAndCacheClineRecommendedModels(): Promise<ClineRecommendedModelsData> {
const clineRecommendedModelsFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.clineRecommendedModels)
let result: ClineRecommendedModelsData = { recommended: [], free: [] }
try {
const apiBaseUrl = ClineEnv.config().apiBaseUrl
const response = await axios.get(`${apiBaseUrl}/api/v1/ai/cline/recommended-models`, getAxiosSettings())
const normalized = normalizeRecommendedModelsResponse(response.data)
if (!normalized) {
throw new Error("Invalid response data when fetching Cline recommended models")
}
result = normalized
await fs.writeFile(clineRecommendedModelsFilePath, JSON.stringify(result))
Logger.log("Cline recommended models fetched and saved")
} catch (error) {
Logger.error("Error fetching Cline recommended models:", error)
try {
const fileExists = await fs
.access(clineRecommendedModelsFilePath)
.then(() => true)
.catch(() => false)
if (fileExists) {
const fileContents = await fs.readFile(clineRecommendedModelsFilePath, "utf8")
const parsed = JSON.parse(fileContents)
if (parsed) {
result = parsed
Logger.log("Loaded Cline recommended models from cache")
}
}
} catch (cacheError) {
Logger.error("Error reading Cline recommended models from cache:", cacheError)
}
}
// Avoid pinning empty results in memory for the full TTL after a transient API/cache miss.
if (result.recommended.length > 0 || result.free.length > 0) {
inMemoryCache = { data: result, timestamp: Date.now() }
}
return result
}
@@ -0,0 +1,29 @@
import { EmptyRequest } from "@shared/proto/cline/common"
import { ClineRecommendedModel, ClineRecommendedModelsResponse } from "@shared/proto/cline/models"
import type { Controller } from "../index"
import { refreshClineRecommendedModels } from "./refreshClineRecommendedModels"
export async function refreshClineRecommendedModelsRpc(
_controller: Controller,
_request: EmptyRequest,
): Promise<ClineRecommendedModelsResponse> {
const models = await refreshClineRecommendedModels()
return ClineRecommendedModelsResponse.create({
recommended: models.recommended.map((model) =>
ClineRecommendedModel.create({
id: model.id,
name: model.name,
description: model.description,
tags: model.tags,
}),
),
free: models.free.map((model) =>
ClineRecommendedModel.create({
id: model.id,
name: model.name,
description: model.description,
tags: model.tags,
}),
),
})
}
+23 -3
View File
@@ -7,6 +7,7 @@ import {
CHAT_COMPLETIONS_API,
DEFAULT_EXTERNAL_OCA_BASE_URL,
DEFAULT_INTERNAL_OCA_BASE_URL,
MESSAGES_API,
RESPONSES_API,
} from "@/services/auth/oca/utils/constants"
import { createOcaHeaders } from "@/services/auth/oca/utils/utils"
@@ -63,9 +64,16 @@ export async function refreshOcaModels(controller: Controller, request: StringRe
}
const modelInfo = model.model_info
const supportedApiList = modelInfo.supported_api_list ?? [CHAT_COMPLETIONS_API]
const apiFormat: ApiFormat = supportedApiList.includes(RESPONSES_API)
? ApiFormat.OPENAI_RESPONSES
: ApiFormat.OPENAI_CHAT
let apiFormat: ApiFormat = ApiFormat.OPENAI_CHAT
if (supportsChatCompletions(supportedApiList)) {
apiFormat = ApiFormat.OPENAI_CHAT
} else if (supportsResponses(supportedApiList)) {
apiFormat = ApiFormat.OPENAI_RESPONSES
} else if (supportsMessages(supportedApiList)) {
apiFormat = ApiFormat.ANTHROPIC_CHAT
}
models[modelId] = OcaModelInfo.create({
maxTokens: model.litellm_params?.max_tokens || -1,
contextWindow: modelInfo.context_window,
@@ -179,3 +187,15 @@ export async function refreshOcaModels(controller: Controller, request: StringRe
}
return OcaCompatibleModelInfo.create({ models })
}
function supportsChatCompletions(modelSupportedApiList: any): boolean {
return modelSupportedApiList.includes(CHAT_COMPLETIONS_API)
}
function supportsResponses(modelSupportedApiList: any): boolean {
return modelSupportedApiList.includes(RESPONSES_API)
}
function supportsMessages(modelSupportedApiList: any): boolean {
return modelSupportedApiList.includes(MESSAGES_API)
}
@@ -11,8 +11,8 @@ import {
CLAUDE_SONNET_1M_TIERS,
openRouterClaudeOpus461mModelId,
openRouterClaudeSonnet41mModelId,
openRouterClaudeSonnet461mModelId,
openRouterClaudeSonnet451mModelId,
openRouterClaudeSonnet461mModelId,
} from "@/shared/api"
import { getAxiosSettings } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
@@ -119,7 +119,7 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
const rawModels = response.data.data
const parsePrice = (price: any) => {
if (price) {
return parseFloat(price) * 1_000_000
return Number.parseFloat(price) * 1_000_000
}
return undefined
}
@@ -68,8 +68,8 @@ function deriveTemperature(modelId: string): number | undefined {
return 0.7
}
// Gemini 3.0 recommends temperature 1.0
if (modelId.startsWith("google/gemini-3.0") || modelId === "google/gemini-3.0") {
// Gemini 3 models recommend temperature 1.0
if (modelId.startsWith("google/gemini-3")) {
return 1.0
}
@@ -1,3 +1,4 @@
import { AgentConfigLoader } from "@core/task/tools/subagent/AgentConfigLoader"
import { CLINE_MCP_TOOL_IDENTIFIER, McpServer } from "@/shared/mcp"
import { ModelFamily } from "@/shared/prompts"
import { ClineDefaultTool } from "@/shared/tools"
@@ -104,6 +105,46 @@ export class ClineToolSet {
return enabledTools
}
private static getDynamicSubagentToolSpecs(variant: PromptVariant, context: SystemPromptContext): ClineToolSpec[] {
if (context.subagentsEnabled !== true || context.isSubagentRun) {
return []
}
const requestedIds = variant.tools ? [...variant.tools] : []
const shouldIncludeSubagentTools = requestedIds.length === 0 || requestedIds.includes(ClineDefaultTool.USE_SUBAGENTS)
if (!shouldIncludeSubagentTools) {
return []
}
const agentConfigs = AgentConfigLoader.getInstance().getAllCachedConfigsWithToolNames()
return agentConfigs.map(({ toolName, config }) => ({
variant: variant.family,
id: ClineDefaultTool.USE_SUBAGENTS,
name: toolName,
description: `Use the "${config.name}" subagent: ${config.description}`,
contextRequirements: (ctx) => ctx.subagentsEnabled === true && !ctx.isSubagentRun,
parameters: [
{
name: "prompt",
required: true,
instruction: "Helpful instruction for the task that the subagent will perform.",
},
],
}))
}
public static getEnabledToolSpecs(variant: PromptVariant, context: SystemPromptContext): ClineToolSpec[] {
const registeredTools = ClineToolSet.getEnabledTools(variant, context).map((tool) => tool.config)
const dynamicSubagentTools = ClineToolSet.getDynamicSubagentToolSpecs(variant, context)
const includesDynamicSubagents = dynamicSubagentTools.length > 0
const filteredRegistered = includesDynamicSubagents
? registeredTools.filter((tool) => tool.id !== ClineDefaultTool.USE_SUBAGENTS)
: registeredTools
return [...filteredRegistered, ...dynamicSubagentTools]
}
/**
* Get the appropriate native tool converter for the given provider
*/
@@ -136,8 +177,7 @@ export class ClineToolSet {
}
// Base set
const toolsets = ClineToolSet.getEnabledTools(variant, context)
const toolConfigs = toolsets.map((tool) => tool.config)
const toolConfigs = ClineToolSet.getEnabledToolSpecs(variant, context)
// MCP tools
const mcpServers = context.mcpHub?.getServers()?.filter((s) => s.disabled !== true) || []
@@ -132,38 +132,15 @@ export class PromptBuilder {
}
}
private static getEnabledTools(variant: PromptVariant, context: SystemPromptContext) {
let resolvedTools: ReturnType<typeof ClineToolSet.getTools> = []
// If the variant explicitly lists tools, resolve each by id with fallback to GENERIC
if (variant?.tools?.length) {
const requestedIds = [...variant.tools]
resolvedTools = ClineToolSet.getToolsForVariantWithFallback(variant.family, requestedIds)
// Preserve requested order
resolvedTools = requestedIds
.map((id) => resolvedTools.find((t) => t.config.id === id))
.filter((t): t is NonNullable<typeof t> => Boolean(t))
} else {
// Otherwise, use all tools registered for the variant, or generic if none
resolvedTools = ClineToolSet.getTools(variant.family)
// Sort by id for stable ordering
resolvedTools = resolvedTools.sort((a, b) => a.config.id.localeCompare(b.config.id))
}
// Filter by context requirements
const enabledTools = resolvedTools.filter(
(tool) => !tool.config.contextRequirements || tool.config.contextRequirements(context),
)
return enabledTools
private static getEnabledTools(variant: PromptVariant, context: SystemPromptContext): ClineToolSpec[] {
return ClineToolSet.getEnabledToolSpecs(variant, context)
}
public static async getToolsPrompts(variant: PromptVariant, context: SystemPromptContext) {
const enabledTools = PromptBuilder.getEnabledTools(variant, context)
const ids = enabledTools.map((tool) => tool.config.id)
return Promise.all(enabledTools.map((tool) => PromptBuilder.tool(tool.config, ids, context)))
const ids = enabledTools.map((tool) => tool.id)
return Promise.all(enabledTools.map((tool) => PromptBuilder.tool(tool, ids, context)))
}
public static tool(config: ClineToolSpec, registry: ClineDefaultTool[], context: SystemPromptContext): string {
@@ -171,7 +148,8 @@ export class PromptBuilder {
if (!config.parameters?.length && !config.description?.length) {
return ""
}
const title = `## ${config.id}`
const displayName = config.name || config.id
const title = `## ${displayName}`
const description = [`Description: ${config.description}`]
if (!config.parameters?.length) {
@@ -209,7 +187,7 @@ export class PromptBuilder {
title,
description.join("\n"),
PromptBuilder.buildParametersSection(filteredParams, context),
PromptBuilder.buildUsageSection(config.id, filteredParams),
PromptBuilder.buildUsageSection(displayName, filteredParams),
]
return sections.filter(Boolean).join("\n")
@@ -13,11 +13,11 @@ export class PromptRegistry {
private static instance: PromptRegistry
private variants: Map<string, PromptVariant> = new Map()
private components: ComponentRegistry = {}
private loaded: boolean = false
public nativeTools: ClineTool[] | undefined = undefined
private constructor() {
registerClineToolSets()
this.load()
}
static getInstance(): PromptRegistry {
@@ -30,42 +30,9 @@ export class PromptRegistry {
/**
* Load all prompts and components on initialization
*/
async load(): Promise<void> {
if (this.loaded) {
return
}
await Promise.all([this.loadVariants(), this.loadComponents()])
// Perform health check to ensure critical variants are available
this.performHealthCheck()
this.loaded = true
}
/**
* Perform health check to ensure registry is in a valid state
*/
private performHealthCheck(): void {
const criticalVariants = [ModelFamily.GENERIC]
const missingVariants = criticalVariants.filter((variant) => !this.variants.has(variant))
if (missingVariants.length > 0) {
Logger.error(`Registry health check failed: Missing critical variants: ${missingVariants.join(", ")}`)
Logger.error(`Available variants: ${Array.from(this.variants.keys()).join(", ")}`)
}
if (this.variants.size === 0) {
Logger.error("Registry health check failed: No variants loaded at all")
}
if (Object.keys(this.components).length === 0) {
Logger.warn("Registry health check warning: No components loaded")
}
Logger.log(
`Registry health check: ${this.variants.size} variants, ${Object.keys(this.components).length} components loaded`,
)
load(): void {
this.loadVariants()
this.loadComponents()
}
getModelFamily(context: SystemPromptContext) {
@@ -89,19 +56,10 @@ export class PromptRegistry {
Logger.log(`[Prompt variant] No matching variant found for model: ${modelId}, falling back to generic`)
return ModelFamily.GENERIC
}
/**
* Get prompt by matching against all registered variants
*/
async get(context: SystemPromptContext): Promise<string> {
await this.load()
// Loop through all registered variants to find the first one that matches
getVariant(context: SystemPromptContext): PromptVariant {
const family = this.getModelFamily(context)
// Fallback to generic variant if no match found
const variant = this.variants.get(family)
const variant = this.variants.get(family) || this.variants.get(ModelFamily.GENERIC)
if (!variant) {
// Enhanced error with debugging information
const availableVariants = Array.from(this.variants.keys())
@@ -110,7 +68,6 @@ export class PromptRegistry {
availableVariants,
variantsCount: this.variants.size,
componentsCount: Object.keys(this.components).length,
isLoaded: this.loaded,
}
Logger.error("Prompt variant lookup failed:", errorDetails)
@@ -118,9 +75,16 @@ export class PromptRegistry {
throw new Error(
`No prompt variant found for model '${context.providerInfo.model.id}' and no generic fallback available. ` +
`Available variants: [${availableVariants.join(", ")}]. ` +
`Registry state: loaded=${this.loaded}, variants=${this.variants.size}, components=${Object.keys(this.components).length}`,
`Registry state: variants=${this.variants.size}, components=${Object.keys(this.components).length}`,
)
}
return variant
}
/**
* Get prompt by matching against all registered variants
*/
async get(context: SystemPromptContext): Promise<string> {
const variant = this.getVariant(context)
// Hacky way to get native tools for the current variant - it's bad and ugly
this.nativeTools = ClineToolSet.getNativeTools(variant, context)
@@ -138,8 +102,6 @@ export class PromptRegistry {
context: SystemPromptContext,
isNextGenModelFamily?: boolean,
): Promise<string> {
await this.load()
// If isNextGenModelFamily is true, prioritize next-gen variant with the specified version
if (isNextGenModelFamily) {
const nextGenVariant = this.variants.get(ModelFamily.NEXT_GEN)
@@ -181,8 +143,6 @@ export class PromptRegistry {
context?: SystemPromptContext,
isNextGenModelFamily?: boolean,
): Promise<string> {
await this.load()
if (!context) {
throw new Error("Context is required for prompt building")
}
@@ -316,7 +276,7 @@ export class PromptRegistry {
/**
* Load all components from the components directory
*/
private async loadComponents(): Promise<void> {
private loadComponents(): void {
try {
// Register each component function
const componentMappings = getSystemPromptComponents()
@@ -0,0 +1,29 @@
import { ModelFamily } from "@/shared/prompts"
import { ClineDefaultTool } from "@/shared/tools"
import type { ClineToolSpec } from "../spec"
const GENERIC: ClineToolSpec = {
variant: ModelFamily.GENERIC,
id: ClineDefaultTool.CHANGE_DIRECTORY,
name: "change_directory",
description: `Request to change the current working directory for all subsequent operations. This changes the base directory used for file operations, terminal commands, and path resolution. Use this when you need to work in a different project or directory than the one you started in.
Important notes:
- The path must be an absolute path to an existing directory
- After changing directory, all relative paths will resolve against the new directory
- File listings in environment_details will reflect the new directory
- New terminal sessions will start in the new directory
- Checkpoints will be disabled after changing directory
- This tool is only available in CLI environments`,
contextRequirements: (context) => context.isCliEnvironment === true,
parameters: [
{
name: "path",
required: true,
instruction: "The absolute path of the directory to change to. Must be an existing directory.",
usage: "/Users/username/projects/other-project",
},
],
}
export const change_directory_variants = [GENERIC]
@@ -4,6 +4,7 @@ export * from "./apply_patch"
export * from "./ask_followup_question"
export * from "./attempt_completion"
export * from "./browser_action"
export * from "./change_directory"
export * from "./execute_command"
export * from "./focus_chain"
export * from "./init"
@@ -6,6 +6,7 @@ import { apply_patch_variants } from "./apply_patch"
import { ask_followup_question_variants } from "./ask_followup_question"
import { attempt_completion_variants } from "./attempt_completion"
import { browser_action_variants } from "./browser_action"
import { change_directory_variants } from "./change_directory"
import { execute_command_variants } from "./execute_command"
import { focus_chain_variants } from "./focus_chain"
import { generate_explanation_variants } from "./generate_explanation"
@@ -55,6 +56,7 @@ export function registerClineToolSets(): void {
...web_search_variants,
...write_to_file_variants,
...apply_patch_variants,
...change_directory_variants,
]
// Register each variant
@@ -66,6 +66,7 @@ export const config = createVariant(ModelFamily.GEMINI_3)
ClineDefaultTool.GENERATE_EXPLANATION,
ClineDefaultTool.USE_SKILL,
ClineDefaultTool.USE_SUBAGENTS,
ClineDefaultTool.CHANGE_DIRECTORY,
)
.placeholders({
MODEL_FAMILY: ModelFamily.GEMINI_3,
@@ -74,6 +74,7 @@ export const config = createVariant(ModelFamily.GENERIC)
ClineDefaultTool.GENERATE_EXPLANATION,
ClineDefaultTool.USE_SKILL,
ClineDefaultTool.USE_SUBAGENTS,
ClineDefaultTool.CHANGE_DIRECTORY,
)
.placeholders({
MODEL_FAMILY: "generic",
@@ -54,6 +54,7 @@ export const config = createVariant(ModelFamily.GLM)
ClineDefaultTool.GENERATE_EXPLANATION,
ClineDefaultTool.USE_SKILL,
ClineDefaultTool.USE_SUBAGENTS,
ClineDefaultTool.CHANGE_DIRECTORY,
)
.placeholders({
MODEL_FAMILY: ModelFamily.GLM,
@@ -65,6 +65,7 @@ export const config = createVariant(ModelFamily.GPT_5)
ClineDefaultTool.GENERATE_EXPLANATION,
ClineDefaultTool.USE_SKILL,
ClineDefaultTool.USE_SUBAGENTS,
ClineDefaultTool.CHANGE_DIRECTORY,
)
.placeholders({
MODEL_FAMILY: ModelFamily.GPT_5,
@@ -56,6 +56,7 @@ export const config = createVariant(ModelFamily.HERMES)
ClineDefaultTool.GENERATE_EXPLANATION,
ClineDefaultTool.USE_SKILL,
ClineDefaultTool.USE_SUBAGENTS,
ClineDefaultTool.CHANGE_DIRECTORY,
)
.placeholders({
MODEL_FAMILY: "hermes",
@@ -72,6 +72,7 @@ export const config = createVariant(ModelFamily.NATIVE_GPT_5_1)
ClineDefaultTool.GENERATE_EXPLANATION,
ClineDefaultTool.USE_SKILL,
ClineDefaultTool.USE_SUBAGENTS,
ClineDefaultTool.CHANGE_DIRECTORY,
)
.placeholders({
MODEL_FAMILY: ModelFamily.NATIVE_GPT_5_1,
@@ -78,6 +78,7 @@ export const config = createVariant(ModelFamily.NATIVE_GPT_5)
ClineDefaultTool.GENERATE_EXPLANATION,
ClineDefaultTool.USE_SKILL,
ClineDefaultTool.USE_SUBAGENTS,
ClineDefaultTool.CHANGE_DIRECTORY,
)
.placeholders({
MODEL_FAMILY: ModelFamily.NATIVE_GPT_5,
@@ -64,6 +64,7 @@ export const config = createVariant(ModelFamily.NATIVE_NEXT_GEN)
ClineDefaultTool.GENERATE_EXPLANATION,
ClineDefaultTool.USE_SKILL,
ClineDefaultTool.USE_SUBAGENTS,
ClineDefaultTool.CHANGE_DIRECTORY,
)
.placeholders({
MODEL_FAMILY: ModelFamily.NATIVE_NEXT_GEN,

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