mirror of
https://github.com/cline/cline.git
synced 2026-09-07 22:16:30 +08:00
Compare commits
72
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34b2f60394 | ||
|
|
e987c00d7c | ||
|
|
c309edd157 | ||
|
|
8de56b5a96 | ||
|
|
7ab3d95dc7 | ||
|
|
a62df28da4 | ||
|
|
00d9a1f925 | ||
|
|
b560f1f381 | ||
|
|
4d60a8afd8 | ||
|
|
744a87be5d | ||
|
|
9ff0573c2e | ||
|
|
a06af825a2 | ||
|
|
187aa19c00 | ||
|
|
716172d05c | ||
|
|
fae0c4674b | ||
|
|
81a69e9ad9 | ||
|
|
e867f4520b | ||
|
|
a2ca96cae1 | ||
|
|
edc088fe5f | ||
|
|
720cf5ff3f | ||
|
|
3037b393a8 | ||
|
|
7eb7897ff2 | ||
|
|
797565f203 | ||
|
|
3d312a4bb8 | ||
|
|
aa3f38d1d9 | ||
|
|
302967f679 | ||
|
|
8c859c182c | ||
|
|
83e062e743 | ||
|
|
264e2c9835 | ||
|
|
81a6fba11c | ||
|
|
9da3c59553 | ||
|
|
0e01b99e76 | ||
|
|
ba5f22f58c | ||
|
|
1cbfc7197e | ||
|
|
4d8f9ea1a3 | ||
|
|
40b0b0c236 | ||
|
|
8c5e1289bf | ||
|
|
4cddde58ed | ||
|
|
9d45accc3b | ||
|
|
57760c1e20 | ||
|
|
fc33e8dbd9 | ||
|
|
b1dcaf576a | ||
|
|
419f2cea73 | ||
|
|
54d8c695a3 | ||
|
|
5a62ab8564 | ||
|
|
f81afb51b5 | ||
|
|
864419d8b0 | ||
|
|
218db38544 | ||
|
|
a3b3295461 | ||
|
|
d09270940f | ||
|
|
23c9bf1c05 | ||
|
|
52531d935a | ||
|
|
19d4248381 | ||
|
|
b2b29678a2 | ||
|
|
ad30714608 | ||
|
|
9eebc44f24 | ||
|
|
6ddf48d227 | ||
|
|
ee59f81706 | ||
|
|
92f6e28f13 | ||
|
|
27a78f0248 | ||
|
|
2c4980f42a | ||
|
|
64c5e48edb | ||
|
|
3c23f80a94 | ||
|
|
4be362892f | ||
|
|
cdff084652 | ||
|
|
299a4a9520 | ||
|
|
c497698beb | ||
|
|
292133989b | ||
|
|
f5aa035a6a | ||
|
|
bbcae25542 | ||
|
|
9b914912ac | ||
|
|
8c369eeed9 |
@@ -8,6 +8,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- legacy-extension
|
||||
workflow_call:
|
||||
|
||||
# Set default permissions for all jobs
|
||||
|
||||
+100
@@ -1,5 +1,105 @@
|
||||
# Changelog
|
||||
|
||||
## [4.0.11]
|
||||
|
||||
### Added
|
||||
|
||||
- Add Claude Opus 5 across the Anthropic, Claude Code, Bedrock, Vertex, Cline, and OpenRouter providers, including 1M context window variants.
|
||||
- Add Moonshot Kimi K3 support.
|
||||
- Include the host plugin version in telemetry events.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Correct pricing for the Claude Opus 1M context variants, which overstated costs for requests above 200k tokens.
|
||||
- Enable native tool calling for Kimi K3 models, fixing empty responses.
|
||||
|
||||
## [4.0.10]
|
||||
|
||||
### Added
|
||||
|
||||
- Add telemetry to track when Cline reaches the consecutive mistake limit.
|
||||
|
||||
## [4.0.9]
|
||||
|
||||
### Added
|
||||
|
||||
- Add GPT-5.6 ChatGPT subscription models.
|
||||
|
||||
### Changed
|
||||
|
||||
- Soften and shorten the message shown when Cline hits the consecutive mistake limit.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Handle cumulative usage snapshots from OpenAI-compatible providers so token counts are no longer over-reported.
|
||||
- Load skills from files saved as UTF-8 with a byte-order mark (BOM).
|
||||
|
||||
## [4.0.8]
|
||||
|
||||
### Added
|
||||
|
||||
- Add more models to the GCP Vertex provider, plus a free-form entry option in the model dropdown for specifying custom Vertex models.
|
||||
|
||||
## [4.0.7]
|
||||
|
||||
### Added
|
||||
|
||||
- Add a ClinePass limit-reached error with a one-click option to switch to Cline usage-based billing.
|
||||
- Allow selecting Cline free models on the ClinePass provider, organized into Subscribed and Free tabs with model descriptions.
|
||||
|
||||
### Changed
|
||||
|
||||
- Refine ClinePass onboarding and provider settings copy, and open the "learn more" link via the in-app URL handler.
|
||||
- Remove the Cline model picker recommendation copy.
|
||||
|
||||
### Removed
|
||||
|
||||
- Remove all references to GLM 5.1.
|
||||
|
||||
## [4.0.6]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Generalize the model capability warning so it applies more broadly.
|
||||
|
||||
## [4.0.5]
|
||||
|
||||
### Added
|
||||
|
||||
- Add support for Claude Sonnet 5 across the Anthropic, Bedrock, Vertex, Claude Code, SAP AI Core, OpenRouter, and Vercel AI Gateway providers, including model picker and recommended-model updates.
|
||||
|
||||
## [4.0.4]
|
||||
|
||||
### Changed
|
||||
|
||||
- Fully remove the ClinePass feature flag so ClinePass is available everywhere in the UI — onboarding, settings, the welcome promo banner, and the credit-limit "Switch to ClinePass" action.
|
||||
|
||||
## [4.0.3]
|
||||
|
||||
### Changed
|
||||
|
||||
- Enable the ClinePass provider for all users by removing the feature-flag gate that previously fell back to the standard Cline provider.
|
||||
|
||||
## [4.0.2]
|
||||
|
||||
### Added
|
||||
|
||||
- Add reasoning effort support (including `xhigh`) for DeepSeek thinking models.
|
||||
- Improve the ClinePass provider experience with clearer reasoning controls and model selection.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Show reasoning effort controls for ClinePass models and align ClinePass model resolution with the rest of the provider.
|
||||
- Prefer canonical Cline Z.ai model ids and polish ClinePass and Z.ai model metadata.
|
||||
- Fix environment variable replacement in the webview.
|
||||
- Default focus chain settings in webview state so the toggle reflects the correct value on load.
|
||||
|
||||
## [4.0.1]
|
||||
|
||||
### Changed
|
||||
|
||||
- Roll the stable VS Code extension back to the pre-SDK-migration codebase to resolve regressions reported in 4.0.0. This release ships the 3.89.2 extension code under a higher version number so existing 4.0.0 users receive the update. SDK-migration work continues separately on `main`.
|
||||
|
||||
## [3.89.2]
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.29
|
||||
|
||||
- Costs are now hidden for Cline free models
|
||||
- Fixed Z.ai model metadata resolution for Z.ai models accessed through the Cline provider
|
||||
- Reverted the model-name-only display change from v3.0.28; the model picker, selector, and status bar return to their previous display behavior
|
||||
|
||||
## 3.0.28
|
||||
|
||||
- Added a ClinePass onboarding flow with selectable ClinePass models, plus improved ClinePass error handling
|
||||
- Added hub primitive catalogs and refreshed the hub dashboard design with a dedicated customizations breakout
|
||||
- Auto-approve toggles now apply immediately when changed
|
||||
- Feature flags now resolve using your user ID on startup
|
||||
- Fixed Cline model display names so they resolve by model name
|
||||
- Truncate large tool results by default (including MCP and custom tool output) to keep requests within context budget
|
||||
- Hardened parallel tool-call guidance for faster, more reliable multi-tool execution
|
||||
|
||||
## 3.0.27
|
||||
|
||||
- Added a `cline skill` command to install and manage skills, matching `cline plugin install` and `cline mcp` (installs default to the Cline agent directory)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.27",
|
||||
"version": "3.0.29",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,36 +1,2 @@
|
||||
export type ConnectorCatalogEntry = {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export const CONNECTOR_CATALOG: ConnectorCatalogEntry[] = [
|
||||
{
|
||||
name: "discord",
|
||||
description:
|
||||
"Discord interactions and gateway bridge backed by RPC runtime sessions",
|
||||
},
|
||||
{
|
||||
name: "gchat",
|
||||
description: "Google Chat webhook bridge backed by RPC runtime sessions",
|
||||
},
|
||||
{
|
||||
name: "linear",
|
||||
description: "Linear webhook bridge backed by RPC runtime sessions",
|
||||
},
|
||||
{
|
||||
name: "slack",
|
||||
description: "Slack webhook/socket bridge backed by RPC runtime sessions",
|
||||
},
|
||||
{
|
||||
name: "telegram",
|
||||
description: "Bridge Telegram bot messages into RPC chat sessions",
|
||||
},
|
||||
{
|
||||
name: "whatsapp",
|
||||
description: "Bridge WhatsApp webhook messages into RPC chat sessions",
|
||||
},
|
||||
];
|
||||
|
||||
export function listConnectorCatalog(): ConnectorCatalogEntry[] {
|
||||
return CONNECTOR_CATALOG.map((entry) => ({ ...entry }));
|
||||
}
|
||||
export type { ConnectorCatalogEntry } from "@cline/shared";
|
||||
export { CONNECTOR_CATALOG, listConnectorCatalog } from "@cline/shared";
|
||||
|
||||
@@ -125,12 +125,12 @@ describe("buildConnectorStartRequest", () => {
|
||||
io: { writeln: vi.fn(), writeErr: vi.fn() },
|
||||
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
|
||||
systemRules: "Rules",
|
||||
defaultModel: "cline-pass/glm-5.1",
|
||||
defaultModel: "cline-pass/glm-5.2",
|
||||
});
|
||||
|
||||
expect(request.provider).toBe("cline-pass");
|
||||
expect(request.apiKey).toBe("workos:resolved-token");
|
||||
expect(request.model).toBe("cline-pass/glm-5.1");
|
||||
expect(request.model).toBe("cline-pass/glm-5.2");
|
||||
});
|
||||
|
||||
it("uses auth material resolved by provider settings manager", async () => {
|
||||
@@ -153,11 +153,11 @@ describe("buildConnectorStartRequest", () => {
|
||||
io: { writeln: vi.fn(), writeErr: vi.fn() },
|
||||
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
|
||||
systemRules: "Rules",
|
||||
defaultModel: "cline-pass/glm-5.1",
|
||||
defaultModel: "cline-pass/glm-5.2",
|
||||
});
|
||||
|
||||
expect(request.provider).toBe("cline-pass");
|
||||
expect(request.apiKey).toBe("workos:resolved-token");
|
||||
expect(request.model).toBe("cline-pass/glm-5.1");
|
||||
expect(request.model).toBe("cline-pass/glm-5.2");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,11 +27,25 @@ const outputMocks = vi.hoisted(() => ({
|
||||
c: { dim: "", reset: "" },
|
||||
}));
|
||||
|
||||
const sessionEventsMocks = vi.hoisted(() => ({
|
||||
listener: undefined as ((event: unknown) => void) | undefined,
|
||||
subscribeToAgentEvents: vi.fn(
|
||||
(_: unknown, listener: (event: unknown) => void) => {
|
||||
sessionEventsMocks.listener = listener;
|
||||
return () => {};
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
const CLINE_PASS_SUBSCRIPTION_URL =
|
||||
"https://app.cline.bot/dashboard/subscription/";
|
||||
"https://app.cline.bot/dashboard/subscription?personal=true";
|
||||
const CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLINE_PASS_SUBSCRIPTION_URL}`;
|
||||
const CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE =
|
||||
"Organization accounts cannot use ClinePass subscriptions. Go to /account -> change account to switch to your personal account for ClinePass";
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
getClineOrgIndividualInferenceSubscriptionMessage: () =>
|
||||
CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE,
|
||||
getClinePassSubscriptionUrl: () => CLINE_PASS_SUBSCRIPTION_URL,
|
||||
isClineNotSubscribedError: (error: unknown) =>
|
||||
error instanceof Error && error.name === "ClineNotSubscribedError",
|
||||
@@ -39,6 +53,15 @@ vi.mock("@cline/core", () => ({
|
||||
text
|
||||
.toLowerCase()
|
||||
.includes("the user is not subscribed to required model plan"),
|
||||
isClineOrgIndividualInferenceSubscriptionError: (error: unknown) =>
|
||||
error instanceof Error &&
|
||||
error.name === "ClineOrgIndividualInferenceSubscriptionError",
|
||||
isClineOrgIndividualInferenceSubscriptionMessage: (text: string) =>
|
||||
text
|
||||
.toLowerCase()
|
||||
.includes(
|
||||
"organization accounts cannot use individual model inference subscriptions",
|
||||
),
|
||||
prewarmFileIndex: vi.fn(async () => undefined),
|
||||
SessionSource: {
|
||||
CLI: "cli",
|
||||
@@ -88,7 +111,7 @@ vi.mock("./prompt", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./session-events", () => ({
|
||||
subscribeToAgentEvents: vi.fn(() => () => {}),
|
||||
subscribeToAgentEvents: sessionEventsMocks.subscribeToAgentEvents,
|
||||
}));
|
||||
|
||||
describe("runAgent", () => {
|
||||
@@ -112,6 +135,9 @@ describe("runAgent", () => {
|
||||
outputMocks.writeln.mockReset();
|
||||
outputMocks.emitJsonLine.mockReset();
|
||||
outputMocks.setActiveCliSession.mockReset();
|
||||
sessionEventsMocks.listener = undefined;
|
||||
sessionEventsMocks.subscribeToAgentEvents.mockClear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -938,4 +964,121 @@ describe("runAgent", () => {
|
||||
expect.stringContaining("est. cost"),
|
||||
);
|
||||
});
|
||||
|
||||
it("zeros Cline free model costs in JSON results and agent events", async () => {
|
||||
const startedAt = new Date("2026-03-22T00:00:00.000Z");
|
||||
const endedAt = new Date("2026-03-22T00:00:01.000Z");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "deepseek/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}),
|
||||
);
|
||||
sessionManagerMocks.start.mockResolvedValue({
|
||||
sessionId: "session-1",
|
||||
manifestPath: "/tmp/manifest.json",
|
||||
messagesPath: "/tmp/messages.json",
|
||||
manifest: {
|
||||
session_id: "session-1",
|
||||
},
|
||||
result: {
|
||||
text: "completed text",
|
||||
usage: {
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: 0.25,
|
||||
},
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
iterations: 1,
|
||||
finishReason: "completed",
|
||||
model: {
|
||||
id: "deepseek/deepseek-v4-flash",
|
||||
provider: "cline",
|
||||
info: {},
|
||||
},
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationMs: 1000,
|
||||
},
|
||||
});
|
||||
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue({
|
||||
usage: {
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: 0.25,
|
||||
},
|
||||
aggregateUsage: {
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: 0.25,
|
||||
},
|
||||
});
|
||||
|
||||
const { runAgent } = await import("./run-agent");
|
||||
const { handleEvent } = await import("../utils/events");
|
||||
|
||||
await expect(
|
||||
runAgent("test prompt", {
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
cwd: process.cwd(),
|
||||
enableAgentTeams: false,
|
||||
enableSpawnAgent: false,
|
||||
enableTools: [],
|
||||
execution: {
|
||||
maxConsecutiveMistakes: 3,
|
||||
},
|
||||
logger: undefined,
|
||||
mode: "yolo",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
outputMode: "json",
|
||||
providerId: "cline",
|
||||
systemPrompt: "system",
|
||||
thinking: false,
|
||||
toolPolicies: { "*": { autoApprove: true } },
|
||||
verbose: false,
|
||||
workspaceRoot: process.cwd(),
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const runResult = outputMocks.emitJsonLine.mock.calls.find(
|
||||
([, payload]) =>
|
||||
(payload as { type?: string } | undefined)?.type === "run_result",
|
||||
)?.[1] as
|
||||
| {
|
||||
usage?: { totalCost?: number };
|
||||
aggregateUsage?: { totalCost?: number };
|
||||
}
|
||||
| undefined;
|
||||
expect(runResult?.usage?.totalCost).toBe(0);
|
||||
expect(runResult?.aggregateUsage?.totalCost).toBe(0);
|
||||
|
||||
sessionEventsMocks.listener?.({
|
||||
type: "usage",
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
cost: 0.25,
|
||||
totalCost: 0.25,
|
||||
});
|
||||
|
||||
expect(handleEvent).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "usage",
|
||||
cost: 0,
|
||||
totalCost: 0,
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,11 @@ import {
|
||||
} from "../utils/approval";
|
||||
import { formatCliErrorMessage } from "../utils/cline-pass-errors";
|
||||
import { handleEvent, handleTeamEvent } from "../utils/events";
|
||||
import {
|
||||
shouldZeroClineFreeModelCost,
|
||||
zeroCliAgentEventCost,
|
||||
zeroCliUsageCost,
|
||||
} from "../utils/free-model-cost";
|
||||
import { createRuntimeHooks } from "../utils/hooks";
|
||||
import {
|
||||
c,
|
||||
@@ -184,8 +189,10 @@ export async function runAgent(
|
||||
let reasoningChunkCount = 0;
|
||||
let redactedReasoningChunkCount = 0;
|
||||
const displayedErrorMessages = new Set<string>();
|
||||
const shouldZeroCost = await shouldZeroClineFreeModelCost(config);
|
||||
|
||||
const onAgentEvent = (event: AgentEvent): void => {
|
||||
const onAgentEvent = (rawEvent: AgentEvent): void => {
|
||||
const event = zeroCliAgentEventCost(rawEvent, shouldZeroCost);
|
||||
if (event.type === "content_start" && event.contentType === "reasoning") {
|
||||
reasoningChunkCount += 1;
|
||||
if (event.redacted) {
|
||||
@@ -339,8 +346,14 @@ export async function runAgent(
|
||||
const usageSummary = await sessionManager.getAccumulatedUsage(
|
||||
started.sessionId,
|
||||
);
|
||||
const aggregateUsage = usageSummary?.aggregateUsage;
|
||||
const usage = aggregateUsage ?? usageSummary?.usage ?? result.usage;
|
||||
const aggregateUsage = zeroCliUsageCost(
|
||||
usageSummary?.aggregateUsage,
|
||||
shouldZeroCost,
|
||||
);
|
||||
const usage = zeroCliUsageCost(
|
||||
aggregateUsage ?? usageSummary?.usage ?? result.usage,
|
||||
shouldZeroCost,
|
||||
);
|
||||
|
||||
if (config.outputMode === "json") {
|
||||
emitJsonLine("stdout", {
|
||||
|
||||
@@ -24,6 +24,11 @@ import { disableOpenTuiGraphicsProbe } from "../tui/opentui-env";
|
||||
import type { QueuedPromptItem } from "../tui/types";
|
||||
import { type ChatCommandState, chatCommandHost } from "../utils/chat-commands";
|
||||
import { applyCliCompactionMode } from "../utils/compaction-mode";
|
||||
import {
|
||||
shouldZeroClineFreeModelCost,
|
||||
zeroCliAgentEventCost,
|
||||
zeroCliUsageCost,
|
||||
} from "../utils/free-model-cost";
|
||||
import {
|
||||
prepareTerminalForPostTuiOutput,
|
||||
writeErr,
|
||||
@@ -153,6 +158,7 @@ export async function runInteractive(
|
||||
askQuestionRef: tuiAskQuestion,
|
||||
});
|
||||
const providerSettingsManager = new ProviderSettingsManager();
|
||||
let zeroCurrentTurnCost = false;
|
||||
|
||||
const sessionRuntime = createInteractiveSessionRuntime({
|
||||
config,
|
||||
@@ -166,7 +172,7 @@ export async function runInteractive(
|
||||
resolveMistakeLimitDecision,
|
||||
switchToActModeTool,
|
||||
onAgentEvent: (event) => {
|
||||
uiEvents.emit("agent", event);
|
||||
uiEvents.emit("agent", zeroCliAgentEventCost(event, zeroCurrentTurnCost));
|
||||
},
|
||||
onTeamEvent: (event) => {
|
||||
uiEvents.emit("team", event);
|
||||
@@ -432,6 +438,7 @@ export async function runInteractive(
|
||||
},
|
||||
onSubmit: async (input, mode, delivery, attachments, onCommandOutput) => {
|
||||
let commandOutput: string | undefined;
|
||||
let zeroTurnCost = false;
|
||||
try {
|
||||
await sessionRuntime.ensureReady();
|
||||
await waitForSubmittedMode(mode);
|
||||
@@ -478,6 +485,8 @@ export async function runInteractive(
|
||||
}
|
||||
input = chatCommandResult.input;
|
||||
commandOutput = chatCommandResult.commandOutput;
|
||||
zeroTurnCost = await shouldZeroClineFreeModelCost(config);
|
||||
zeroCurrentTurnCost = zeroTurnCost;
|
||||
const {
|
||||
prompt: userInput,
|
||||
userImages,
|
||||
@@ -519,8 +528,9 @@ export async function runInteractive(
|
||||
}
|
||||
if (result.finishReason !== "completed") {
|
||||
if (result.finishReason === "aborted" || isAbortInProgress()) {
|
||||
const usage = await sessionRuntime.getAccumulatedUsage(
|
||||
result.usage,
|
||||
const usage = zeroCliUsageCost(
|
||||
await sessionRuntime.getAccumulatedUsage(result.usage),
|
||||
zeroTurnCost,
|
||||
);
|
||||
return {
|
||||
usage,
|
||||
@@ -535,7 +545,10 @@ export async function runInteractive(
|
||||
errorText || `Turn finished with ${result.finishReason}`,
|
||||
);
|
||||
}
|
||||
const usage = await sessionRuntime.getAccumulatedUsage(result.usage);
|
||||
const usage = zeroCliUsageCost(
|
||||
await sessionRuntime.getAccumulatedUsage(result.usage),
|
||||
zeroTurnCost,
|
||||
);
|
||||
return {
|
||||
usage,
|
||||
currentContextSize: getCurrentContextSize(result.messages),
|
||||
@@ -559,6 +572,7 @@ export async function runInteractive(
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
zeroCurrentTurnCost = false;
|
||||
if (!delivery) {
|
||||
isRunning = false;
|
||||
clearAbortInProgress();
|
||||
|
||||
@@ -3,7 +3,9 @@ import type React from "react";
|
||||
import { useState } from "react";
|
||||
import "opentui-spinner/react";
|
||||
import {
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClinePassSubscriptionUrl,
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage,
|
||||
isClinePassSubscriptionError,
|
||||
} from "../../utils/cline-pass-errors";
|
||||
import {
|
||||
@@ -329,6 +331,30 @@ function ClinePassSubscriptionErrorView(props: { defaultFg?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ClineOrgIndividualInferenceSubscriptionErrorView(props: {
|
||||
defaultFg?: string;
|
||||
}) {
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg="yellow" content="* " />
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor="yellow"
|
||||
paddingX={1}
|
||||
>
|
||||
<text fg="yellow">Personal ClinePass required</text>
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content={getClineOrgIndividualInferenceSubscriptionMessage()}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatEntryView(props: {
|
||||
entry: ChatEntry;
|
||||
accent?: string;
|
||||
@@ -427,6 +453,11 @@ export function ChatEntryView(props: {
|
||||
if (isClineAccountCreditsErrorMessage(entry.text)) {
|
||||
return <ClineCreditsErrorView defaultFg={defaultFg} />;
|
||||
}
|
||||
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(entry.text)) {
|
||||
return (
|
||||
<ClineOrgIndividualInferenceSubscriptionErrorView defaultFg={defaultFg} />
|
||||
);
|
||||
}
|
||||
if (isClinePassSubscriptionError(entry.text)) {
|
||||
return <ClinePassSubscriptionErrorView defaultFg={defaultFg} />;
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ describe("formatStatusBarUsageText", () => {
|
||||
totalCost: 0.123,
|
||||
showCost: true,
|
||||
}),
|
||||
).toBe("(12,345) $0.12");
|
||||
).toBe("(12,345 tokens) $0.12");
|
||||
});
|
||||
|
||||
it("omits cost when usage cost is hidden", () => {
|
||||
@@ -67,6 +67,6 @@ describe("formatStatusBarUsageText", () => {
|
||||
totalCost: 0.123,
|
||||
showCost: false,
|
||||
}),
|
||||
).toBe("(12,345)");
|
||||
).toBe("(12,345 tokens)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ export function formatStatusBarUsageText(input: {
|
||||
totalCost: number;
|
||||
showCost: boolean;
|
||||
}): string {
|
||||
const tokens = `(${input.totalTokens.toLocaleString()})`;
|
||||
const tokens = `(${input.totalTokens.toLocaleString()} tokens)`;
|
||||
if (!input.showCost) return tokens;
|
||||
return `${tokens} ${formatCost(input.totalCost)}`;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatCliErrorMessage,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClinePassSubscriptionUrl,
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage,
|
||||
isClinePassSubscriptionError,
|
||||
} from "./cline-pass-errors";
|
||||
|
||||
@@ -20,7 +22,23 @@ describe("cline-pass-errors", () => {
|
||||
|
||||
it("formats the ClinePass subscription URL", () => {
|
||||
expect(getClinePassSubscriptionUrl()).toBe(
|
||||
"https://app.cline.bot/dashboard/subscription/",
|
||||
"https://app.cline.bot/dashboard/subscription?personal=true",
|
||||
);
|
||||
});
|
||||
|
||||
it("recognizes and formats organization account individual subscription errors", () => {
|
||||
const raw =
|
||||
"403 Error 403: organization accounts cannot use individual model inference subscriptions";
|
||||
const formatted = getClineOrgIndividualInferenceSubscriptionMessage();
|
||||
|
||||
expect(isClineOrgIndividualInferenceSubscriptionErrorMessage(raw)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage(
|
||||
new Error(formatted),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toBe(formatted);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import {
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClinePassSubscriptionUrl,
|
||||
isClineNotSubscribedError,
|
||||
isClineNotSubscribedMessage,
|
||||
isClineOrgIndividualInferenceSubscriptionError,
|
||||
isClineOrgIndividualInferenceSubscriptionMessage,
|
||||
} from "@cline/core";
|
||||
|
||||
export { getClinePassSubscriptionUrl };
|
||||
export {
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClinePassSubscriptionUrl,
|
||||
};
|
||||
|
||||
function isFormattedClinePassSubscriptionMessage(message: string): boolean {
|
||||
const normalized = message.trim().toLowerCase();
|
||||
@@ -32,7 +38,30 @@ export function isClinePassSubscriptionError(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function isClineOrgIndividualInferenceSubscriptionErrorMessage(
|
||||
error: unknown,
|
||||
): boolean {
|
||||
if (isClineOrgIndividualInferenceSubscriptionError(error)) {
|
||||
return true;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.name === "ClineOrgIndividualInferenceSubscriptionError" ||
|
||||
isClineOrgIndividualInferenceSubscriptionMessage(error.message) ||
|
||||
error.message === getClineOrgIndividualInferenceSubscriptionMessage()
|
||||
);
|
||||
}
|
||||
return (
|
||||
typeof error === "string" &&
|
||||
(isClineOrgIndividualInferenceSubscriptionMessage(error) ||
|
||||
error === getClineOrgIndividualInferenceSubscriptionMessage())
|
||||
);
|
||||
}
|
||||
|
||||
export function formatCliErrorMessage(error: unknown): string {
|
||||
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(error)) {
|
||||
return getClineOrgIndividualInferenceSubscriptionMessage();
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { AgentEvent } from "@cline/core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearClineFreeModelCostCache,
|
||||
shouldZeroClineFreeModelCost,
|
||||
zeroCliAgentEventCost,
|
||||
zeroCliUsageCost,
|
||||
} from "./free-model-cost";
|
||||
|
||||
afterEach(() => {
|
||||
clearClineFreeModelCostCache();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("shouldZeroClineFreeModelCost", () => {
|
||||
it("uses the Cline free model list", async () => {
|
||||
const fetchMock = vi.fn(
|
||||
async (_input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "deepseek/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
},
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
"https://cline.test/api/v1/ai/cline/recommended-models",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not zero non-Cline providers", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "openrouter",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not match a paid model by only the final path segment", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "deepseek/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "acme/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("retries after a failed free model list fetch", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response("unavailable", { status: 503 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "deepseek/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("zeroCliUsageCost", () => {
|
||||
it("zeros total cost while preserving token usage", () => {
|
||||
expect(
|
||||
zeroCliUsageCost(
|
||||
{
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalCost: 0.001,
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toEqual({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalCost: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("zeroCliAgentEventCost", () => {
|
||||
it("zeros usage event cost fields", () => {
|
||||
const event = {
|
||||
type: "usage",
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cost: 0.001,
|
||||
totalCost: 0.001,
|
||||
} as AgentEvent;
|
||||
|
||||
expect(zeroCliAgentEventCost(event, true)).toMatchObject({
|
||||
cost: 0,
|
||||
totalCost: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("zeros done event usage cost", () => {
|
||||
const event = {
|
||||
type: "done",
|
||||
reason: "completed",
|
||||
text: "ok",
|
||||
iterations: 1,
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalCost: 0.001,
|
||||
},
|
||||
} as AgentEvent;
|
||||
|
||||
expect(zeroCliAgentEventCost(event, true)).toMatchObject({
|
||||
usage: { totalCost: 0 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { AgentEvent } from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import type { Config } from "./types";
|
||||
|
||||
const CLINE_RECOMMENDED_MODELS_TIMEOUT_MS = 5_000;
|
||||
const freeModelIdsByBaseUrl = new Map<
|
||||
string,
|
||||
Promise<readonly string[] | undefined>
|
||||
>();
|
||||
|
||||
function normalizeModelId(modelId: string | undefined): string {
|
||||
return modelId?.trim().toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
function modelIdsMatch(selectedModelId: string, freeModelId: string): boolean {
|
||||
const selected = normalizeModelId(selectedModelId);
|
||||
const free = normalizeModelId(freeModelId);
|
||||
if (!selected || !free) return false;
|
||||
return selected === free;
|
||||
}
|
||||
|
||||
function resolveClineRecommendedModelsUrl(baseUrl: string): string {
|
||||
const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
|
||||
const apiBaseUrl = normalizedBaseUrl.endsWith("/api/v1")
|
||||
? normalizedBaseUrl.slice(0, -"/api/v1".length)
|
||||
: normalizedBaseUrl;
|
||||
return `${apiBaseUrl}/api/v1/ai/cline/recommended-models`;
|
||||
}
|
||||
|
||||
async function fetchClineFreeModelIds(
|
||||
baseUrl: string,
|
||||
): Promise<readonly string[] | undefined> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
CLINE_RECOMMENDED_MODELS_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
const response = await fetch(resolveClineRecommendedModelsUrl(baseUrl), {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) return undefined;
|
||||
const json = (await response.json()) as { free?: unknown };
|
||||
return Array.isArray(json.free)
|
||||
? json.free
|
||||
.map((model) =>
|
||||
model && typeof model === "object"
|
||||
? (model as Record<string, unknown>).id
|
||||
: undefined,
|
||||
)
|
||||
.filter((id): id is string => typeof id === "string" && id.length > 0)
|
||||
: [];
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function getClineFreeModelIds(baseUrl: string): Promise<readonly string[]> {
|
||||
const cacheKey = baseUrl.trim();
|
||||
let cached = freeModelIdsByBaseUrl.get(cacheKey);
|
||||
if (!cached) {
|
||||
cached = fetchClineFreeModelIds(cacheKey).then((ids) => {
|
||||
if (!ids) freeModelIdsByBaseUrl.delete(cacheKey);
|
||||
return ids;
|
||||
});
|
||||
freeModelIdsByBaseUrl.set(cacheKey, cached);
|
||||
}
|
||||
return cached.then((ids) => ids ?? []);
|
||||
}
|
||||
|
||||
export async function shouldZeroClineFreeModelCost(
|
||||
config: Pick<Config, "providerId" | "modelId" | "baseUrl">,
|
||||
): Promise<boolean> {
|
||||
if (config.providerId !== "cline") return false;
|
||||
const modelId = normalizeModelId(config.modelId);
|
||||
if (!modelId) return false;
|
||||
|
||||
const baseUrl =
|
||||
config.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl;
|
||||
const freeModelIds = await getClineFreeModelIds(baseUrl);
|
||||
return freeModelIds.some((freeModelId) =>
|
||||
modelIdsMatch(modelId, freeModelId),
|
||||
);
|
||||
}
|
||||
|
||||
export function zeroCliUsageCost<T extends { totalCost?: number } | undefined>(
|
||||
usage: T,
|
||||
shouldZeroCost: boolean,
|
||||
): T {
|
||||
if (
|
||||
!shouldZeroCost ||
|
||||
!usage ||
|
||||
typeof usage.totalCost !== "number" ||
|
||||
usage.totalCost === 0
|
||||
) {
|
||||
return usage;
|
||||
}
|
||||
return { ...usage, totalCost: 0 } as T;
|
||||
}
|
||||
|
||||
export function zeroCliAgentEventCost(
|
||||
event: AgentEvent,
|
||||
shouldZeroCost: boolean,
|
||||
): AgentEvent {
|
||||
if (!shouldZeroCost) return event;
|
||||
if (event.type === "done" && event.usage) {
|
||||
return {
|
||||
...event,
|
||||
usage: zeroCliUsageCost(event.usage, true),
|
||||
};
|
||||
}
|
||||
if (event.type !== "usage") return event;
|
||||
const next = { ...event } as Record<string, unknown>;
|
||||
if (typeof next.cost === "number") next.cost = 0;
|
||||
if (typeof next.totalCost === "number") next.totalCost = 0;
|
||||
return next as unknown as AgentEvent;
|
||||
}
|
||||
|
||||
export function clearClineFreeModelCostCache(): void {
|
||||
freeModelIdsByBaseUrl.clear();
|
||||
}
|
||||
@@ -1,325 +1,16 @@
|
||||
export interface PlatformDef {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "polling" | "webhook" | "hybrid";
|
||||
hint: string;
|
||||
fields: FieldDef[];
|
||||
security?: SecurityDef;
|
||||
}
|
||||
import {
|
||||
CONNECTOR_PLATFORMS,
|
||||
shouldIncludeConnectorField,
|
||||
} from "@cline/shared";
|
||||
|
||||
export interface FieldDef {
|
||||
flag: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
help?: string[];
|
||||
initialValue?: string;
|
||||
options?: Array<{ value: string; label: string; hint?: string }>;
|
||||
includeWhen?: FieldCondition;
|
||||
}
|
||||
export type {
|
||||
ConnectorFieldCondition as FieldCondition,
|
||||
ConnectorFieldDef as FieldDef,
|
||||
ConnectorPlatformDef as PlatformDef,
|
||||
ConnectorSecurityDef as SecurityDef,
|
||||
ConnectorSecurityFieldDef as SecurityFieldDef,
|
||||
} from "@cline/shared";
|
||||
export { CONNECTOR_PLATFORMS, shouldIncludeConnectorField };
|
||||
|
||||
export type FieldCondition = {
|
||||
flag: string;
|
||||
equals?: string;
|
||||
notEquals?: string;
|
||||
};
|
||||
|
||||
export interface SecurityFieldDef {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
help?: string[];
|
||||
requiredMessage: string;
|
||||
validate?: (value: string) => string | undefined;
|
||||
}
|
||||
|
||||
export interface SecurityDef {
|
||||
prompt: string;
|
||||
fields: SecurityFieldDef[];
|
||||
buildArgs: (values: Record<string, string>) => string[];
|
||||
}
|
||||
|
||||
export function shouldIncludeField(
|
||||
field: FieldDef,
|
||||
values: Record<string, string>,
|
||||
): boolean {
|
||||
const condition = field.includeWhen;
|
||||
if (!condition) {
|
||||
return true;
|
||||
}
|
||||
const value = values[condition.flag] ?? "";
|
||||
if (condition.equals !== undefined && value !== condition.equals) {
|
||||
return false;
|
||||
}
|
||||
if (condition.notEquals !== undefined && value === condition.notEquals) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateTelegramUserId(value: string): string | undefined {
|
||||
return /^\d+$/.test(value)
|
||||
? undefined
|
||||
: "Telegram user ID must contain digits only";
|
||||
}
|
||||
|
||||
function validateSlackTeamId(value: string): string | undefined {
|
||||
return /^T[A-Z0-9]+$/.test(value)
|
||||
? undefined
|
||||
: "Slack workspace ID must start with T and contain uppercase letters or digits only";
|
||||
}
|
||||
|
||||
function validateSlackUserId(value: string): string | undefined {
|
||||
return /^[UW][A-Z0-9]+$/.test(value)
|
||||
? undefined
|
||||
: "Slack member ID must start with U or W and contain uppercase letters or digits only";
|
||||
}
|
||||
|
||||
export const PLATFORMS: PlatformDef[] = [
|
||||
{
|
||||
id: "telegram",
|
||||
name: "Telegram",
|
||||
type: "polling",
|
||||
hint: "Easiest to set up. No public URL needed.",
|
||||
fields: [
|
||||
{
|
||||
flag: "-k",
|
||||
label: "Bot token",
|
||||
placeholder: "7123456789:AAH...",
|
||||
required: true,
|
||||
help: [
|
||||
"Open Telegram and start a chat with @BotFather",
|
||||
"Send /newbot and follow the prompts",
|
||||
"BotFather gives you this after creating the bot",
|
||||
"It looks like 7123456789:AAHxxx...",
|
||||
],
|
||||
},
|
||||
],
|
||||
security: {
|
||||
prompt:
|
||||
"By default, anyone who finds your bot can message it and run tasks on your machine. Restrict access to your Telegram user ID?",
|
||||
fields: [
|
||||
{
|
||||
key: "userId",
|
||||
label: "Your Telegram user ID",
|
||||
placeholder: "123456789",
|
||||
help: [
|
||||
"Message @userinfobot on Telegram",
|
||||
"It will reply with your numeric user ID",
|
||||
],
|
||||
requiredMessage: "User ID is required to restrict access",
|
||||
validate: validateTelegramUserId,
|
||||
},
|
||||
],
|
||||
buildArgs: ({ userId }) => ["--allowed-user-id", userId ?? ""],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "slack",
|
||||
name: "Slack",
|
||||
type: "hybrid",
|
||||
hint: "Public URL for webhook mode; leave blank for socket mode.",
|
||||
fields: [
|
||||
{
|
||||
flag: "--bot-token",
|
||||
label: "Bot token",
|
||||
placeholder: "xoxb-...",
|
||||
required: true,
|
||||
help: [
|
||||
"Go to api.slack.com/apps and create a new app",
|
||||
"Add Bot Token Scopes: chat:write, app_mentions:read, channels:history, channels:read, im:history, im:read, im:write, users:read",
|
||||
"Install to workspace and copy the Bot Token",
|
||||
],
|
||||
},
|
||||
{
|
||||
flag: "--base-url",
|
||||
label: "Public base URL",
|
||||
placeholder: "leave blank for socket mode",
|
||||
help: [
|
||||
"Enter a publicly accessible URL for webhook mode",
|
||||
"Leave blank to use Slack socket mode instead",
|
||||
],
|
||||
},
|
||||
{
|
||||
flag: "--signing-secret",
|
||||
label: "Signing secret",
|
||||
required: true,
|
||||
help: ["Found in your app's Basic Information page"],
|
||||
includeWhen: { flag: "--base-url", notEquals: "" },
|
||||
},
|
||||
{
|
||||
flag: "--app-token",
|
||||
label: "App-level token",
|
||||
placeholder: "xapp-...",
|
||||
required: true,
|
||||
help: [
|
||||
"Enable Socket Mode in the Slack app",
|
||||
"Generate an app-level token with the connections:write scope",
|
||||
],
|
||||
includeWhen: { flag: "--base-url", equals: "" },
|
||||
},
|
||||
],
|
||||
security: {
|
||||
prompt: "Restrict which Slack users can interact with the bot?",
|
||||
fields: [
|
||||
{
|
||||
key: "teamId",
|
||||
label: "Allowed Slack workspace ID",
|
||||
placeholder: "T01ABC123",
|
||||
help: [
|
||||
"Open your Slack workspace URL in a browser",
|
||||
"The workspace ID is the segment after /client/, for example T01ABC123",
|
||||
],
|
||||
requiredMessage: "Workspace ID is required to restrict access",
|
||||
validate: validateSlackTeamId,
|
||||
},
|
||||
{
|
||||
key: "userId",
|
||||
label: "Allowed Slack member ID",
|
||||
placeholder: "U01ABC123",
|
||||
help: [
|
||||
"Click a user's name in Slack, then View full profile",
|
||||
"Click ... and Copy member ID",
|
||||
],
|
||||
requiredMessage: "Member ID is required to restrict access",
|
||||
validate: validateSlackUserId,
|
||||
},
|
||||
],
|
||||
buildArgs: ({ teamId, userId }) => [
|
||||
"--hook-command",
|
||||
`jq -r ".payload.actor.participantKey" | grep -qx "slack:team:${teamId}:user:${userId}" && echo '{"action":"allow"}' || echo '{"action":"deny"}'`,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "discord",
|
||||
name: "Discord",
|
||||
type: "webhook",
|
||||
hint: "Requires a Discord app and public URL.",
|
||||
fields: [
|
||||
{
|
||||
flag: "--application-id",
|
||||
label: "Application ID",
|
||||
required: true,
|
||||
help: [
|
||||
"Go to discord.com/developers/applications",
|
||||
"Create a new app, copy the Application ID",
|
||||
],
|
||||
},
|
||||
{
|
||||
flag: "--bot-token",
|
||||
label: "Bot token",
|
||||
required: true,
|
||||
help: ["Go to Bot section, create a bot, copy the token"],
|
||||
},
|
||||
{
|
||||
flag: "--public-key",
|
||||
label: "Public key",
|
||||
required: true,
|
||||
help: ["Found in General Information of your app"],
|
||||
},
|
||||
{
|
||||
flag: "--base-url",
|
||||
label: "Public base URL",
|
||||
placeholder: "https://example.com",
|
||||
required: true,
|
||||
help: [
|
||||
"Base URL for the connector",
|
||||
"For Discord, set the Interactions Endpoint URL to <base-url>/api/webhooks/discord",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "whatsapp",
|
||||
name: "WhatsApp",
|
||||
type: "webhook",
|
||||
hint: "Requires Meta developer account and public URL.",
|
||||
fields: [
|
||||
{
|
||||
flag: "--phone-number-id",
|
||||
label: "Phone number ID",
|
||||
required: true,
|
||||
help: ["From your WhatsApp Business account in Meta Developer portal"],
|
||||
},
|
||||
{
|
||||
flag: "--access-token",
|
||||
label: "Access token",
|
||||
required: true,
|
||||
help: ["Generate a permanent token in Meta Developer portal"],
|
||||
},
|
||||
{
|
||||
flag: "--app-secret",
|
||||
label: "App secret",
|
||||
required: true,
|
||||
help: ["Found in App Settings > Basic"],
|
||||
},
|
||||
{
|
||||
flag: "--verify-token",
|
||||
label: "Webhook verify token",
|
||||
placeholder: "my-verify-token",
|
||||
required: true,
|
||||
help: ["Any string you choose, used to verify webhook setup"],
|
||||
},
|
||||
{
|
||||
flag: "--base-url",
|
||||
label: "Public base URL",
|
||||
placeholder: "https://example.com",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "gchat",
|
||||
name: "Google Chat",
|
||||
type: "webhook",
|
||||
hint: "Requires Google Cloud project and public URL.",
|
||||
fields: [
|
||||
{
|
||||
flag: "--credentials-json",
|
||||
label: "Service account credentials JSON",
|
||||
required: true,
|
||||
help: [
|
||||
"Create a service account in Google Cloud Console",
|
||||
"Download the credentials JSON file",
|
||||
"Paste the JSON content here",
|
||||
],
|
||||
},
|
||||
{
|
||||
flag: "--base-url",
|
||||
label: "Public base URL",
|
||||
placeholder: "https://example.com",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "linear",
|
||||
name: "Linear",
|
||||
type: "webhook",
|
||||
hint: "React to Linear issues and comments.",
|
||||
fields: [
|
||||
{
|
||||
flag: "--api-key",
|
||||
label: "API key",
|
||||
required: true,
|
||||
help: ["Go to Linear Settings > API > Personal API keys"],
|
||||
},
|
||||
{
|
||||
flag: "--webhook-secret",
|
||||
label: "Webhook signing secret",
|
||||
required: true,
|
||||
help: [
|
||||
"Go to Settings > API > Webhooks, create one",
|
||||
"Copy the signing secret",
|
||||
],
|
||||
},
|
||||
{
|
||||
flag: "--base-url",
|
||||
label: "Public base URL",
|
||||
placeholder: "https://example.com",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
export const PLATFORMS = CONNECTOR_PLATFORMS;
|
||||
export const shouldIncludeField = shouldIncludeConnectorField;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isIP } from "node:net";
|
||||
|
||||
export interface ClineHubServerOptions {
|
||||
host: string;
|
||||
port: number;
|
||||
@@ -47,6 +49,9 @@ function normalizePublicUrl(
|
||||
`PUBLIC_URL must use http: or https:, got ${parsed.protocol}`,
|
||||
);
|
||||
}
|
||||
if (shouldAddDashboardPortToPublicUrl(parsed, port)) {
|
||||
parsed.port = String(port);
|
||||
}
|
||||
parsed.hash = "";
|
||||
return parsed.toString().replace(/\/$/, "");
|
||||
}
|
||||
@@ -85,12 +90,26 @@ export function resolveClineHubServerOptions(
|
||||
};
|
||||
}
|
||||
|
||||
function isDefaultProtocolPort(url: URL, port: number): boolean {
|
||||
return (
|
||||
(url.protocol === "http:" && port === 80) ||
|
||||
(url.protocol === "https:" && port === 443)
|
||||
);
|
||||
}
|
||||
|
||||
function shouldAddDashboardPortToPublicUrl(url: URL, port: number): boolean {
|
||||
if (url.port || isDefaultProtocolPort(url, port)) return false;
|
||||
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
||||
return hostname === "localhost" || isIP(hostname) !== 0;
|
||||
}
|
||||
|
||||
export function buildInviteUrl(
|
||||
publicUrl: string,
|
||||
roomSecret: string | undefined,
|
||||
): string {
|
||||
if (!roomSecret) return publicUrl;
|
||||
const url = new URL(publicUrl);
|
||||
url.searchParams.set("roomSecret", roomSecret);
|
||||
if (roomSecret) {
|
||||
url.searchParams.set("roomSecret", roomSecret);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
handleToolApprovalResponse,
|
||||
rejectOrphanedApprovals,
|
||||
} from "./server/approvals";
|
||||
import { isAuthorizedBrowserToDesktopRequest } from "./server/browser-auth";
|
||||
import {
|
||||
browserConfig,
|
||||
host,
|
||||
@@ -14,7 +15,11 @@ import {
|
||||
webviewDistDir,
|
||||
} from "./server/deps";
|
||||
import { handleDesktopCommand } from "./server/desktop-commands";
|
||||
import { createJsonResponse, WebviewAssets } from "./server/http";
|
||||
import {
|
||||
createJsonResponse,
|
||||
isWebviewRoute,
|
||||
WebviewAssets,
|
||||
} from "./server/http";
|
||||
import {
|
||||
attachHub,
|
||||
detachHub,
|
||||
@@ -53,17 +58,33 @@ export interface ClineHubDashboardServer {
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
const PUBLIC_BROWSER_PATHS = new Set([
|
||||
"/version",
|
||||
"/health",
|
||||
"/config.json",
|
||||
"/api/marketplace/catalog",
|
||||
"/icon.png",
|
||||
"/icon.svg",
|
||||
"/icon.ico",
|
||||
"/32x32.png",
|
||||
"/cline-logo-filled.svg",
|
||||
"/favicon.svg",
|
||||
]);
|
||||
|
||||
function isPublicStaticAssetPath(pathname: string): boolean {
|
||||
return pathname.startsWith("/assets/") || PUBLIC_BROWSER_PATHS.has(pathname);
|
||||
}
|
||||
|
||||
function isPublicBrowserRoute(_req: Request, url: URL): boolean {
|
||||
return isWebviewRoute(url.pathname) || isPublicStaticAssetPath(url.pathname);
|
||||
}
|
||||
|
||||
export async function startClineHubDashboardServer(): Promise<ClineHubDashboardServer> {
|
||||
const ctx = new HubContext();
|
||||
const assets = new WebviewAssets(webviewDistDir);
|
||||
const syncClientsAndSessions = () => syncHubClientsAndSessions(ctx);
|
||||
let stopped = false;
|
||||
|
||||
function isAuthorizedBrowserRequest(url: URL): boolean {
|
||||
if (!roomSecret) return true;
|
||||
return url.searchParams.get("roomSecret") === roomSecret;
|
||||
}
|
||||
|
||||
await attachHub(ctx);
|
||||
const healthInterval = setInterval(() => {
|
||||
void (async () => {
|
||||
@@ -77,6 +98,21 @@ export async function startClineHubDashboardServer(): Promise<ClineHubDashboardS
|
||||
hostname: host,
|
||||
async fetch(req, server) {
|
||||
const url = new URL(req.url);
|
||||
if (
|
||||
!isAuthorizedBrowserToDesktopRequest(
|
||||
req,
|
||||
url,
|
||||
{
|
||||
bindHost: host,
|
||||
port,
|
||||
publicUrl,
|
||||
roomSecret,
|
||||
},
|
||||
isPublicBrowserRoute,
|
||||
)
|
||||
) {
|
||||
return createJsonResponse({ error: "unauthorized_browser" }, 403);
|
||||
}
|
||||
if (url.pathname === "/version") {
|
||||
return createJsonResponse({ coreVersion: CORE_BUILD_VERSION });
|
||||
}
|
||||
@@ -85,9 +121,6 @@ export async function startClineHubDashboardServer(): Promise<ClineHubDashboardS
|
||||
return createJsonResponse(hubStatusPayload(ctx));
|
||||
}
|
||||
if (url.pathname === "/browser") {
|
||||
if (!isAuthorizedBrowserRequest(url)) {
|
||||
return createJsonResponse({ error: "invalid_room_secret" }, 401);
|
||||
}
|
||||
const displayName = `Browser ${Math.random().toString(36).slice(2, 6)}`;
|
||||
const data = {
|
||||
socket: undefined as never,
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
allowedBrowserHosts,
|
||||
allowedBrowserOrigins,
|
||||
isAuthorizedBrowserRequest,
|
||||
isAuthorizedBrowserToDesktopRequest,
|
||||
requiresBrowserRequestAuth,
|
||||
} from "./browser-auth";
|
||||
|
||||
const defaultOptions = {
|
||||
bindHost: "127.0.0.1",
|
||||
port: 8787,
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
};
|
||||
|
||||
const publicRoute = (_req: Request, url: URL) => url.pathname === "/public";
|
||||
|
||||
function browserRequest(
|
||||
origin?: string,
|
||||
init?: Omit<RequestInit, "headers"> & {
|
||||
headers?: Record<string, string>;
|
||||
},
|
||||
): Request {
|
||||
return new Request("http://127.0.0.1:8787/browser", {
|
||||
...init,
|
||||
headers: {
|
||||
host: "127.0.0.1:8787",
|
||||
...(origin === undefined ? {} : { origin }),
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("allowedBrowserOrigins", () => {
|
||||
it("allows the configured public URL origin and local aliases for local binds", () => {
|
||||
expect([...allowedBrowserOrigins(defaultOptions)].sort()).toEqual([
|
||||
"http://127.0.0.1:8787",
|
||||
"http://[::1]:8787",
|
||||
"http://localhost:8787",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the configured public URL scheme for local aliases", () => {
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserOrigins({
|
||||
...defaultOptions,
|
||||
publicUrl: "https://127.0.0.1:8787",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual([
|
||||
"https://127.0.0.1:8787",
|
||||
"https://[::1]:8787",
|
||||
"https://localhost:8787",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits default protocol ports for local alias origins", () => {
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserOrigins({
|
||||
bindHost: "127.0.0.1",
|
||||
port: 80,
|
||||
publicUrl: "http://localhost",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["http://127.0.0.1", "http://[::1]", "http://localhost"]);
|
||||
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserOrigins({
|
||||
bindHost: "127.0.0.1",
|
||||
port: 443,
|
||||
publicUrl: "https://localhost",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["https://127.0.0.1", "https://[::1]", "https://localhost"]);
|
||||
});
|
||||
|
||||
it("allows the configured public URL origin and explicit bind origin for non-local binds", () => {
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserOrigins({
|
||||
bindHost: "0.0.0.0",
|
||||
port: 8787,
|
||||
publicUrl: "https://example.ngrok-free.app",
|
||||
roomSecret: "secret",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["https://0.0.0.0:8787", "https://example.ngrok-free.app"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("allowedBrowserHosts", () => {
|
||||
it("allows the configured public URL host and local aliases for local binds", () => {
|
||||
expect([...allowedBrowserHosts(defaultOptions)].sort()).toEqual([
|
||||
"127.0.0.1:8787",
|
||||
"[::1]:8787",
|
||||
"localhost:8787",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits default protocol ports for local alias hosts", () => {
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserHosts({
|
||||
bindHost: "127.0.0.1",
|
||||
port: 80,
|
||||
publicUrl: "http://localhost",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["127.0.0.1", "[::1]", "localhost"]);
|
||||
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserHosts({
|
||||
bindHost: "127.0.0.1",
|
||||
port: 443,
|
||||
publicUrl: "https://localhost",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["127.0.0.1", "[::1]", "localhost"]);
|
||||
});
|
||||
|
||||
it("allows the configured public URL host and explicit bind host for non-local binds", () => {
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserHosts({
|
||||
bindHost: "0.0.0.0",
|
||||
port: 8787,
|
||||
publicUrl: "https://example.ngrok-free.app",
|
||||
roomSecret: "secret",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["0.0.0.0:8787", "example.ngrok-free.app"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requiresBrowserRequestAuth", () => {
|
||||
it("does not require browser auth for public GET routes", () => {
|
||||
expect(
|
||||
requiresBrowserRequestAuth(
|
||||
new Request("http://127.0.0.1:8787/public"),
|
||||
new URL("http://127.0.0.1:8787/public"),
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("requires browser auth for unknown paths even when they use GET", () => {
|
||||
expect(
|
||||
requiresBrowserRequestAuth(
|
||||
new Request("http://127.0.0.1:8787/future-api"),
|
||||
new URL("http://127.0.0.1:8787/future-api"),
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires browser auth for privileged paths even when they use GET", () => {
|
||||
expect(
|
||||
requiresBrowserRequestAuth(
|
||||
new Request("http://127.0.0.1:8787/browser"),
|
||||
new URL("http://127.0.0.1:8787/browser"),
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires browser auth for every WebSocket upgrade path", () => {
|
||||
expect(
|
||||
requiresBrowserRequestAuth(
|
||||
new Request("http://127.0.0.1:8787/future-socket", {
|
||||
headers: { upgrade: "websocket" },
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/future-socket"),
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires browser auth for every unsafe HTTP method", () => {
|
||||
expect(
|
||||
requiresBrowserRequestAuth(
|
||||
new Request("http://127.0.0.1:8787/future-api", { method: "POST" }),
|
||||
new URL("http://127.0.0.1:8787/future-api"),
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAuthorizedBrowserRequest", () => {
|
||||
it.each([
|
||||
"http://127.0.0.1:8787",
|
||||
"http://localhost:8787",
|
||||
"http://[::1]:8787",
|
||||
])("accepts local dashboard origin %s without a room secret", (origin) => {
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest(origin),
|
||||
new URL("http://127.0.0.1:8787/browser"),
|
||||
defaultOptions,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
"",
|
||||
"null",
|
||||
"not a url",
|
||||
"http://evil.attacker.example.com",
|
||||
"http://127.0.0.1:9999",
|
||||
"https://127.0.0.1:8787",
|
||||
])("rejects untrusted origin %s", (origin) => {
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest(origin),
|
||||
new URL("http://127.0.0.1:8787/browser"),
|
||||
defaultOptions,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
"",
|
||||
"evil.attacker.example.com",
|
||||
"127.0.0.1:9999",
|
||||
"localhost:9999",
|
||||
])("rejects untrusted host %s", (host) => {
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://127.0.0.1:8787", {
|
||||
headers: host === undefined ? { host: "" } : { host },
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/browser"),
|
||||
defaultOptions,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows explicit wildcard bind host and origin when a room secret is configured", () => {
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://0.0.0.0:8787", {
|
||||
headers: { host: "0.0.0.0:8787" },
|
||||
}),
|
||||
new URL("http://0.0.0.0:8787/browser?roomSecret=invite-123"),
|
||||
{
|
||||
bindHost: "0.0.0.0",
|
||||
port: 8787,
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
roomSecret: "invite-123",
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires trusted origin, trusted host, and room secret when a room secret is configured", () => {
|
||||
const options = { ...defaultOptions, roomSecret: "invite-123" };
|
||||
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://127.0.0.1:8787"),
|
||||
new URL("http://127.0.0.1:8787/browser?roomSecret=invite-123"),
|
||||
options,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://127.0.0.1:8787"),
|
||||
new URL("http://127.0.0.1:8787/browser"),
|
||||
options,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://evil.attacker.example.com"),
|
||||
new URL("http://127.0.0.1:8787/browser?roomSecret=invite-123"),
|
||||
options,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://127.0.0.1:8787", {
|
||||
headers: { host: "evil.attacker.example.com" },
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/browser?roomSecret=invite-123"),
|
||||
options,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAuthorizedBrowserToDesktopRequest", () => {
|
||||
it("allows safe public GET routes without an origin", () => {
|
||||
expect(
|
||||
isAuthorizedBrowserToDesktopRequest(
|
||||
new Request("http://127.0.0.1:8787/public"),
|
||||
new URL("http://127.0.0.1:8787/public"),
|
||||
defaultOptions,
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects future WebSocket paths from untrusted origins by default", () => {
|
||||
expect(
|
||||
isAuthorizedBrowserToDesktopRequest(
|
||||
new Request("http://127.0.0.1:8787/future-socket", {
|
||||
headers: {
|
||||
host: "127.0.0.1:8787",
|
||||
origin: "http://evil.attacker.example.com",
|
||||
upgrade: "websocket",
|
||||
},
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/future-socket"),
|
||||
defaultOptions,
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects future unsafe HTTP routes from untrusted origins by default", () => {
|
||||
expect(
|
||||
isAuthorizedBrowserToDesktopRequest(
|
||||
new Request("http://127.0.0.1:8787/future-api", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
host: "127.0.0.1:8787",
|
||||
origin: "http://evil.attacker.example.com",
|
||||
},
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/future-api"),
|
||||
defaultOptions,
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows future unsafe HTTP routes from trusted origins", () => {
|
||||
expect(
|
||||
isAuthorizedBrowserToDesktopRequest(
|
||||
new Request("http://127.0.0.1:8787/future-api", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
host: "127.0.0.1:8787",
|
||||
origin: "http://127.0.0.1:8787",
|
||||
},
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/future-api"),
|
||||
defaultOptions,
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { isNonLocalBindHost } from "../options";
|
||||
|
||||
export interface BrowserRequestAuthOptions {
|
||||
bindHost: string;
|
||||
port: number;
|
||||
publicUrl: string;
|
||||
roomSecret?: string;
|
||||
}
|
||||
|
||||
const SAFE_HTTP_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
|
||||
export type PublicBrowserRoutePredicate = (req: Request, url: URL) => boolean;
|
||||
|
||||
function isWebSocketUpgrade(req: Request): boolean {
|
||||
return req.headers.get("upgrade")?.toLowerCase() === "websocket";
|
||||
}
|
||||
|
||||
function parseOrigin(value: string | null): string | undefined {
|
||||
const origin = parseHeader(value);
|
||||
try {
|
||||
return new URL(origin ?? "").origin;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseHeader(value: string | null): string | undefined {
|
||||
const host = value?.trim().toLowerCase();
|
||||
return host || undefined;
|
||||
}
|
||||
|
||||
function formatHostForOrigin(host: string): string {
|
||||
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
||||
}
|
||||
|
||||
function isDefaultProtocolPort(protocol: string, port: number): boolean {
|
||||
return (
|
||||
(protocol === "http:" && port === 80) ||
|
||||
(protocol === "https:" && port === 443)
|
||||
);
|
||||
}
|
||||
|
||||
function originForHost(protocol: string, host: string, port: number): string {
|
||||
return new URL(`${protocol}//${formatHostForOrigin(host)}:${port}`).origin;
|
||||
}
|
||||
|
||||
function hostHeaderForHost(
|
||||
protocol: string,
|
||||
host: string,
|
||||
port: number,
|
||||
): string {
|
||||
const formattedHost = formatHostForOrigin(host).toLowerCase();
|
||||
return isDefaultProtocolPort(protocol, port)
|
||||
? formattedHost
|
||||
: `${formattedHost}:${port}`;
|
||||
}
|
||||
|
||||
export function allowedBrowserOrigins({
|
||||
bindHost,
|
||||
port,
|
||||
publicUrl,
|
||||
}: BrowserRequestAuthOptions): Set<string> {
|
||||
const publicUrlParts = new URL(publicUrl);
|
||||
const origins = new Set<string>();
|
||||
origins.add(publicUrlParts.origin);
|
||||
|
||||
origins.add(originForHost(publicUrlParts.protocol, bindHost, port));
|
||||
|
||||
if (!isNonLocalBindHost(bindHost)) {
|
||||
for (const hostname of ["127.0.0.1", "localhost", "[::1]"]) {
|
||||
origins.add(originForHost(publicUrlParts.protocol, hostname, port));
|
||||
}
|
||||
}
|
||||
|
||||
return origins;
|
||||
}
|
||||
|
||||
export function allowedBrowserHosts({
|
||||
bindHost,
|
||||
port,
|
||||
publicUrl,
|
||||
}: BrowserRequestAuthOptions): Set<string> {
|
||||
const publicUrlParts = new URL(publicUrl);
|
||||
const hosts = new Set<string>();
|
||||
const publicHost = publicUrlParts.host.toLowerCase();
|
||||
hosts.add(publicHost);
|
||||
|
||||
hosts.add(hostHeaderForHost(publicUrlParts.protocol, bindHost, port));
|
||||
|
||||
if (!isNonLocalBindHost(bindHost)) {
|
||||
for (const hostname of ["127.0.0.1", "localhost", "[::1]"]) {
|
||||
hosts.add(hostHeaderForHost(publicUrlParts.protocol, hostname, port));
|
||||
}
|
||||
}
|
||||
|
||||
return hosts;
|
||||
}
|
||||
|
||||
export function requiresBrowserRequestAuth(
|
||||
req: Request,
|
||||
url: URL,
|
||||
isPublicBrowserRoute: PublicBrowserRoutePredicate,
|
||||
): boolean {
|
||||
if (isWebSocketUpgrade(req)) return true;
|
||||
if (!SAFE_HTTP_METHODS.has(req.method.toUpperCase())) return true;
|
||||
return !isPublicBrowserRoute(req, url);
|
||||
}
|
||||
|
||||
export function isAuthorizedBrowserRequest(
|
||||
req: Request,
|
||||
url: URL,
|
||||
options: BrowserRequestAuthOptions,
|
||||
): boolean {
|
||||
const host = parseHeader(req.headers.get("host"));
|
||||
if (!host || !allowedBrowserHosts(options).has(host)) return false;
|
||||
|
||||
const origin = parseOrigin(req.headers.get("origin"));
|
||||
if (!origin || !allowedBrowserOrigins(options).has(origin)) return false;
|
||||
|
||||
if (!options.roomSecret) return true;
|
||||
return url.searchParams.get("roomSecret") === options.roomSecret;
|
||||
}
|
||||
|
||||
export function isAuthorizedBrowserToDesktopRequest(
|
||||
req: Request,
|
||||
url: URL,
|
||||
options: BrowserRequestAuthOptions,
|
||||
isPublicBrowserRoute: PublicBrowserRoutePredicate,
|
||||
): boolean {
|
||||
return (
|
||||
!requiresBrowserRequestAuth(req, url, isPublicBrowserRoute) ||
|
||||
isAuthorizedBrowserRequest(req, url, options)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __test__ } from "./connectors";
|
||||
|
||||
describe("connector launch command", () => {
|
||||
it("uses Bun conditions when launching the source CLI from Bun", () => {
|
||||
expect(
|
||||
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
|
||||
execPath: "/Users/test/.bun/bin/bun",
|
||||
cliPath: "/repo/apps/cli/src/index.ts",
|
||||
exists: () => true,
|
||||
}),
|
||||
).toEqual({
|
||||
launcher: "/Users/test/.bun/bin/bun",
|
||||
childArgs: [
|
||||
"--conditions=development",
|
||||
"/repo/apps/cli/src/index.ts",
|
||||
"connect",
|
||||
"telegram",
|
||||
"--bot-token",
|
||||
"token",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses compiled CLI subcommands without Bun flags", () => {
|
||||
expect(
|
||||
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
|
||||
execPath: "/Applications/Cline/bin/cline",
|
||||
cliPath: "/repo/apps/cli/src/index.ts",
|
||||
exists: () => true,
|
||||
}),
|
||||
).toEqual({
|
||||
launcher: "/Applications/Cline/bin/cline",
|
||||
childArgs: ["connect", "telegram", "--bot-token", "token"],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses Bun conditions when launching the source CLI from Node", () => {
|
||||
expect(
|
||||
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
|
||||
execPath: "/usr/local/bin/node",
|
||||
cliPath: "/repo/apps/cli/src/index.ts",
|
||||
exists: () => true,
|
||||
}),
|
||||
).toEqual({
|
||||
launcher: "bun",
|
||||
childArgs: [
|
||||
"--conditions=development",
|
||||
"/repo/apps/cli/src/index.ts",
|
||||
"connect",
|
||||
"telegram",
|
||||
"--bot-token",
|
||||
"token",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("detects Windows Node when launching the source CLI", () => {
|
||||
expect(
|
||||
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
|
||||
execPath: "node.exe",
|
||||
cliPath: "C:\\repo\\apps\\cli\\src\\index.ts",
|
||||
exists: () => true,
|
||||
}),
|
||||
).toEqual({
|
||||
launcher: "bun",
|
||||
childArgs: [
|
||||
"--conditions=development",
|
||||
"C:\\repo\\apps\\cli\\src\\index.ts",
|
||||
"connect",
|
||||
"telegram",
|
||||
"--bot-token",
|
||||
"token",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("strips terminal color codes from connector command failures", () => {
|
||||
expect(
|
||||
__test__.normalizeConnectorError(
|
||||
"\u001B[31merror:\u001B[0m error: unknown option '--conditions=development'",
|
||||
"connector start failed",
|
||||
),
|
||||
).toBe("unknown option '--conditions=development'");
|
||||
});
|
||||
|
||||
it("turns Telegram unauthorized responses into a token validation message", () => {
|
||||
expect(
|
||||
__test__.normalizeConnectorError(
|
||||
"\u001B[31merror:\u001B[0m Telegram getMe failed (401 Unauthorized): Unauthorized",
|
||||
"connector start failed",
|
||||
),
|
||||
).toBe(
|
||||
"Telegram rejected this bot token. Copy the token from @BotFather and try again.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { basename } from "node:path";
|
||||
import process from "node:process";
|
||||
import { withResolvedClineBuildEnv } from "@cline/shared";
|
||||
import { listConnectorCatalog } from "../../../cli/src/connectors/catalog";
|
||||
import { listActiveConnectors } from "../../../cli/src/connectors/status";
|
||||
import {
|
||||
@@ -13,6 +16,68 @@ import type {
|
||||
import { cliIndexPath, workspaceRoot } from "./deps";
|
||||
import { asRecord, asString } from "./utils";
|
||||
|
||||
type CliConnectCommand = {
|
||||
launcher: string;
|
||||
childArgs: string[];
|
||||
};
|
||||
|
||||
const ANSI_ESCAPE_PATTERN = new RegExp(
|
||||
[
|
||||
"[\\u001B\\u009B][[\\]()#;?]*",
|
||||
"(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\\u0007)",
|
||||
"|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))",
|
||||
].join(""),
|
||||
"g",
|
||||
);
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return value.replace(ANSI_ESCAPE_PATTERN, "");
|
||||
}
|
||||
|
||||
function normalizeConnectorError(rawMessage: string, fallback: string): string {
|
||||
const message =
|
||||
stripAnsi(rawMessage)
|
||||
.replace(/\r\n/g, "\n")
|
||||
.trim()
|
||||
.replace(/^(?:error:\s*)+/i, "")
|
||||
.trim() || fallback;
|
||||
|
||||
if (
|
||||
/^Telegram getMe failed \(401 Unauthorized\): Unauthorized$/i.test(message)
|
||||
) {
|
||||
return "Telegram rejected this bot token. Copy the token from @BotFather and try again.";
|
||||
}
|
||||
|
||||
return message.slice(0, 2_000);
|
||||
}
|
||||
|
||||
function buildCliConnectCommand(
|
||||
args: string[],
|
||||
options: {
|
||||
execPath?: string;
|
||||
cliPath?: string;
|
||||
exists?: (path: string) => boolean;
|
||||
} = {},
|
||||
): CliConnectCommand {
|
||||
const execPath = options.execPath ?? process.execPath;
|
||||
const cliPath = options.cliPath ?? cliIndexPath;
|
||||
const exists = options.exists ?? existsSync;
|
||||
const runtimeName = basename(execPath).toLowerCase();
|
||||
const isBunRuntime = runtimeName.includes("bun");
|
||||
const isNodeRuntime = runtimeName === "node" || runtimeName === "node.exe";
|
||||
const useBunSourceEntrypoint =
|
||||
(isBunRuntime || isNodeRuntime) && exists(cliPath);
|
||||
const launcher = isBunRuntime
|
||||
? execPath
|
||||
: useBunSourceEntrypoint
|
||||
? "bun"
|
||||
: execPath;
|
||||
const childArgs = useBunSourceEntrypoint
|
||||
? ["--conditions=development", cliPath, "connect", ...args]
|
||||
: ["connect", ...args];
|
||||
return { launcher, childArgs };
|
||||
}
|
||||
|
||||
export function connectorChannelsPayload(): WebviewConnectorChannelsResponse {
|
||||
const supported = new Set(
|
||||
listConnectorCatalog().map((connector) => connector.name),
|
||||
@@ -55,23 +120,14 @@ async function runCliConnectCommand(args: string[]): Promise<{
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}> {
|
||||
const launcher = (process.versions as Record<string, string | undefined>).bun
|
||||
? process.execPath
|
||||
: "bun";
|
||||
const child = spawn(
|
||||
launcher,
|
||||
["--conditions=development", cliIndexPath, "connect", ...args],
|
||||
{
|
||||
cwd: workspaceRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
CLINE_BUILD_ENV: process.env.CLINE_BUILD_ENV ?? "development",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
const { launcher, childArgs } = buildCliConnectCommand(args);
|
||||
const child = spawn(launcher, childArgs, {
|
||||
cwd: workspaceRoot,
|
||||
env: withResolvedClineBuildEnv(process.env),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout?.setEncoding("utf8");
|
||||
@@ -157,9 +213,10 @@ export async function startConnectorChannel(
|
||||
const result = await runCliConnectCommand(cliArgs);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
(result.stderr.trim() || result.stdout.trim() || "connector start failed")
|
||||
.trim()
|
||||
.slice(0, 2_000),
|
||||
normalizeConnectorError(
|
||||
result.stderr || result.stdout,
|
||||
"connector start failed",
|
||||
),
|
||||
);
|
||||
}
|
||||
await waitForConnectorState(() =>
|
||||
@@ -168,6 +225,11 @@ export async function startConnectorChannel(
|
||||
return connectorChannelsPayload();
|
||||
}
|
||||
|
||||
export const __test__ = {
|
||||
buildCliConnectCommand,
|
||||
normalizeConnectorError,
|
||||
};
|
||||
|
||||
export async function stopConnectorChannel(
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<WebviewConnectorChannelsResponse> {
|
||||
@@ -182,9 +244,10 @@ export async function stopConnectorChannel(
|
||||
const result = await runCliConnectCommand([channel, "--stop"]);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
(result.stderr.trim() || result.stdout.trim() || "connector stop failed")
|
||||
.trim()
|
||||
.slice(0, 2_000),
|
||||
normalizeConnectorError(
|
||||
result.stderr || result.stdout,
|
||||
"connector stop failed",
|
||||
),
|
||||
);
|
||||
}
|
||||
await waitForConnectorState(
|
||||
|
||||
@@ -41,6 +41,23 @@ expectEqual(
|
||||
"invite URL",
|
||||
);
|
||||
|
||||
const tailscale = resolveClineHubServerOptions({
|
||||
HOST: "0.0.0.0",
|
||||
CLINE_HUB_DASHBOARD_PORT: "8787",
|
||||
PUBLIC_URL: "http://100.82.5.118",
|
||||
ROOM_SECRET: "invite-123",
|
||||
});
|
||||
expectEqual(
|
||||
tailscale.publicUrl,
|
||||
"http://100.82.5.118:8787",
|
||||
"direct IP public URL gets dashboard port",
|
||||
);
|
||||
expectEqual(
|
||||
buildInviteUrl(tailscale.publicUrl, tailscale.roomSecret),
|
||||
"http://100.82.5.118:8787/?roomSecret=invite-123",
|
||||
"invite URL for direct IP public URL",
|
||||
);
|
||||
|
||||
expectThrows(
|
||||
() => resolveClineHubServerOptions({ HOST: "0.0.0.0" }),
|
||||
"non-local bind without ROOM_SECRET",
|
||||
|
||||
@@ -13,10 +13,9 @@
|
||||
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@rive-app/react-webgl2": "^4.27.2",
|
||||
"@shikijs/langs": "^4.2.0",
|
||||
"@shikijs/themes": "^4.2.0",
|
||||
"@streamdown/cjk": "^1.0.3",
|
||||
"@streamdown/code": "^1.1.1",
|
||||
"@streamdown/math": "^1.0.2",
|
||||
"@streamdown/mermaid": "^1.0.2",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"ai": "^6.0.116",
|
||||
@@ -27,6 +26,7 @@
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"media-chrome": "^4.18.1",
|
||||
"mermaid": "^11.15.0",
|
||||
"motion": "^12.38.0",
|
||||
"nanoid": "^5.1.7",
|
||||
"next-themes": "^0.4.6",
|
||||
|
||||
@@ -22,7 +22,14 @@ import {
|
||||
WrenchIcon,
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
lazy,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -53,19 +60,24 @@ import type {
|
||||
WebviewOutboundMessage,
|
||||
WebviewSessionSummary,
|
||||
} from "../../webview-protocol";
|
||||
import Chat from "./Chat";
|
||||
import { PageFrame, PageHeader } from "./components/views/page-layout";
|
||||
import {
|
||||
type CustomizationSection,
|
||||
CustomizationSectionView,
|
||||
} from "./components/views/settings/extensions-view";
|
||||
import {
|
||||
type SettingsSection,
|
||||
SettingsView,
|
||||
} from "./components/views/settings/settings-view";
|
||||
import type { CustomizationSection } from "./components/views/settings/extensions-view";
|
||||
import type { SettingsSection } from "./components/views/settings/settings-view";
|
||||
import { syncHubTheme } from "./lib/theme";
|
||||
import { postToHost } from "./vscode";
|
||||
|
||||
const Chat = lazy(() => import("./Chat"));
|
||||
const SettingsView = lazy(() =>
|
||||
import("./components/views/settings/settings-view").then((module) => ({
|
||||
default: module.SettingsView,
|
||||
})),
|
||||
);
|
||||
const CustomizationSectionView = lazy(() =>
|
||||
import("./components/views/settings/extensions-view").then((module) => ({
|
||||
default: module.CustomizationSectionView,
|
||||
})),
|
||||
);
|
||||
|
||||
type View =
|
||||
| "home"
|
||||
| "sessions"
|
||||
@@ -238,6 +250,14 @@ function currentPathWithSearch(): string {
|
||||
return `${window.location.pathname}${window.location.search}`;
|
||||
}
|
||||
|
||||
function ViewLoading() {
|
||||
return (
|
||||
<PageFrame>
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function formatRelativeTime(timestamp?: number): string {
|
||||
if (!timestamp) return "unknown";
|
||||
const elapsed = Math.max(0, Date.now() - timestamp);
|
||||
@@ -1333,7 +1353,7 @@ function App() {
|
||||
|
||||
return (
|
||||
<Shell onNavigate={navigate} version={hubState.coreVersion} view={view}>
|
||||
{content}
|
||||
<Suspense fallback={<ViewLoading />}>{content}</Suspense>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,12 +11,13 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import type {
|
||||
BundledLanguage,
|
||||
BundledTheme,
|
||||
HighlighterGeneric,
|
||||
HighlighterCore,
|
||||
LanguageRegistration,
|
||||
ThemedToken,
|
||||
} from "shiki";
|
||||
import { createHighlighter } from "shiki";
|
||||
ThemeRegistration,
|
||||
} from "shiki/core";
|
||||
import { createHighlighterCore } from "shiki/core";
|
||||
import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
@@ -36,6 +37,78 @@ const isUnderline = (fontStyle: number | undefined) =>
|
||||
// oxlint-disable-next-line eslint(no-bitwise)
|
||||
fontStyle && fontStyle & 4;
|
||||
|
||||
const SUPPORTED_LANGUAGES = [
|
||||
"bash",
|
||||
"css",
|
||||
"diff",
|
||||
"html",
|
||||
"javascript",
|
||||
"json",
|
||||
"jsonc",
|
||||
"jsx",
|
||||
"markdown",
|
||||
"python",
|
||||
"shellscript",
|
||||
"tsx",
|
||||
"typescript",
|
||||
"yaml",
|
||||
] as const;
|
||||
|
||||
export type SupportedCodeLanguage = (typeof SUPPORTED_LANGUAGES)[number];
|
||||
|
||||
const SUPPORTED_LANGUAGE_SET = new Set<string>(SUPPORTED_LANGUAGES);
|
||||
|
||||
const LANGUAGE_LOADERS: Record<
|
||||
SupportedCodeLanguage,
|
||||
() => Promise<LanguageRegistration[]>
|
||||
> = {
|
||||
bash: () => import("@shikijs/langs/bash").then((module) => module.default),
|
||||
css: () => import("@shikijs/langs/css").then((module) => module.default),
|
||||
diff: () => import("@shikijs/langs/diff").then((module) => module.default),
|
||||
html: () => import("@shikijs/langs/html").then((module) => module.default),
|
||||
javascript: () =>
|
||||
import("@shikijs/langs/javascript").then((module) => module.default),
|
||||
json: () => import("@shikijs/langs/json").then((module) => module.default),
|
||||
jsonc: () => import("@shikijs/langs/jsonc").then((module) => module.default),
|
||||
jsx: () => import("@shikijs/langs/jsx").then((module) => module.default),
|
||||
markdown: () =>
|
||||
import("@shikijs/langs/markdown").then((module) => module.default),
|
||||
python: () =>
|
||||
import("@shikijs/langs/python").then((module) => module.default),
|
||||
shellscript: () =>
|
||||
import("@shikijs/langs/shellscript").then((module) => module.default),
|
||||
tsx: () => import("@shikijs/langs/tsx").then((module) => module.default),
|
||||
typescript: () =>
|
||||
import("@shikijs/langs/typescript").then((module) => module.default),
|
||||
yaml: () => import("@shikijs/langs/yaml").then((module) => module.default),
|
||||
};
|
||||
|
||||
const LANGUAGE_ALIASES: Record<string, SupportedCodeLanguage> = {
|
||||
console: "shellscript",
|
||||
cjs: "javascript",
|
||||
htm: "html",
|
||||
js: "javascript",
|
||||
json5: "jsonc",
|
||||
md: "markdown",
|
||||
mjs: "javascript",
|
||||
py: "python",
|
||||
sh: "shellscript",
|
||||
shell: "shellscript",
|
||||
ts: "typescript",
|
||||
yml: "yaml",
|
||||
};
|
||||
|
||||
const normalizeLanguage = (
|
||||
language: string,
|
||||
): SupportedCodeLanguage | "text" => {
|
||||
const normalized = language.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return "text";
|
||||
}
|
||||
const aliased = LANGUAGE_ALIASES[normalized] ?? normalized;
|
||||
return SUPPORTED_LANGUAGE_SET.has(aliased) ? aliased : "text";
|
||||
};
|
||||
|
||||
// Transform tokens to include pre-computed keys to avoid noArrayIndexKey lint
|
||||
interface KeyedToken {
|
||||
token: ThemedToken;
|
||||
@@ -108,7 +181,7 @@ const LineSpan = ({
|
||||
// Types
|
||||
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
|
||||
code: string;
|
||||
language: BundledLanguage;
|
||||
language: string;
|
||||
showLineNumbers?: boolean;
|
||||
};
|
||||
|
||||
@@ -128,10 +201,9 @@ const CodeBlockContext = createContext<CodeBlockContextType>({
|
||||
});
|
||||
|
||||
// Highlighter cache (singleton per language)
|
||||
const highlighterCache = new Map<
|
||||
string,
|
||||
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
|
||||
>();
|
||||
let highlighterPromise: Promise<HighlighterCore> | undefined;
|
||||
let themesPromise: Promise<void> | undefined;
|
||||
const languagePromises = new Map<SupportedCodeLanguage, Promise<void>>();
|
||||
|
||||
// Token cache
|
||||
const tokensCache = new Map<string, TokenizedCode>();
|
||||
@@ -139,27 +211,44 @@ const tokensCache = new Map<string, TokenizedCode>();
|
||||
// Subscribers for async token updates
|
||||
const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>();
|
||||
|
||||
const getTokensCacheKey = (code: string, language: BundledLanguage) => {
|
||||
const getTokensCacheKey = (code: string, language: string) => {
|
||||
const start = code.slice(0, 100);
|
||||
const end = code.length > 100 ? code.slice(-100) : "";
|
||||
return `${language}:${code.length}:${start}:${end}`;
|
||||
};
|
||||
|
||||
const getHighlighter = (
|
||||
language: BundledLanguage,
|
||||
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
|
||||
const cached = highlighterCache.get(language);
|
||||
const getHighlighter = (): Promise<HighlighterCore> => {
|
||||
if (!highlighterPromise) {
|
||||
highlighterPromise = createHighlighterCore({
|
||||
engine: createJavaScriptRegexEngine({ forgiving: true }),
|
||||
});
|
||||
}
|
||||
return highlighterPromise;
|
||||
};
|
||||
|
||||
const ensureThemes = (highlighter: HighlighterCore): Promise<void> => {
|
||||
if (!themesPromise) {
|
||||
themesPromise = Promise.all([
|
||||
import("@shikijs/themes/github-light").then((module) => module.default),
|
||||
import("@shikijs/themes/github-dark").then((module) => module.default),
|
||||
]).then((themes: ThemeRegistration[]) => highlighter.loadTheme(...themes));
|
||||
}
|
||||
return themesPromise;
|
||||
};
|
||||
|
||||
const ensureLanguage = (
|
||||
highlighter: HighlighterCore,
|
||||
language: SupportedCodeLanguage,
|
||||
): Promise<void> => {
|
||||
const cached = languagePromises.get(language);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const highlighterPromise = createHighlighter({
|
||||
langs: [language],
|
||||
themes: ["github-light", "github-dark"],
|
||||
});
|
||||
|
||||
highlighterCache.set(language, highlighterPromise);
|
||||
return highlighterPromise;
|
||||
const languagePromise = LANGUAGE_LOADERS[language]().then((registrations) =>
|
||||
highlighter.loadLanguage(...registrations),
|
||||
);
|
||||
languagePromises.set(language, languagePromise);
|
||||
return languagePromise;
|
||||
};
|
||||
|
||||
// Create raw tokens for immediate display while highlighting loads
|
||||
@@ -181,11 +270,16 @@ const createRawTokens = (code: string): TokenizedCode => ({
|
||||
// Synchronous highlight with callback for async results
|
||||
export const highlightCode = (
|
||||
code: string,
|
||||
language: BundledLanguage,
|
||||
language: string,
|
||||
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
|
||||
callback?: (result: TokenizedCode) => void,
|
||||
): TokenizedCode | null => {
|
||||
const tokensCacheKey = getTokensCacheKey(code, language);
|
||||
const langToUse = normalizeLanguage(language);
|
||||
if (langToUse === "text") {
|
||||
return createRawTokens(code);
|
||||
}
|
||||
|
||||
const tokensCacheKey = getTokensCacheKey(code, langToUse);
|
||||
|
||||
// Return cached result if available
|
||||
const cached = tokensCache.get(tokensCacheKey);
|
||||
@@ -202,11 +296,11 @@ export const highlightCode = (
|
||||
}
|
||||
|
||||
// Start highlighting in background - fire-and-forget async pattern
|
||||
getHighlighter(language)
|
||||
getHighlighter()
|
||||
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then)
|
||||
.then((highlighter) => {
|
||||
const availableLangs = highlighter.getLoadedLanguages();
|
||||
const langToUse = availableLangs.includes(language) ? language : "text";
|
||||
.then(async (highlighter) => {
|
||||
await ensureThemes(highlighter);
|
||||
await ensureLanguage(highlighter, langToUse);
|
||||
|
||||
const result = highlighter.codeToTokens(code, {
|
||||
lang: langToUse,
|
||||
@@ -376,7 +470,7 @@ export const CodeBlockContent = ({
|
||||
showLineNumbers = false,
|
||||
}: {
|
||||
code: string;
|
||||
language: BundledLanguage;
|
||||
language: string;
|
||||
showLineNumbers?: boolean;
|
||||
}) => {
|
||||
// Memoized raw tokens for immediate display
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { cjk } from "@streamdown/cjk";
|
||||
import { code } from "@streamdown/code";
|
||||
import { math } from "@streamdown/math";
|
||||
import { mermaid } from "@streamdown/mermaid";
|
||||
import type { UIMessage } from "ai";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||
@@ -16,7 +12,6 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group";
|
||||
import {
|
||||
@@ -26,6 +21,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HubStreamdown } from "./streamdown";
|
||||
|
||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
@@ -316,24 +312,18 @@ export const MessageBranchPage = ({
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
|
||||
|
||||
const streamdownPlugins = { cjk, code, math, mermaid };
|
||||
export type MessageResponseProps = ComponentProps<typeof HubStreamdown>;
|
||||
|
||||
export const MessageResponse = memo(
|
||||
({ className, ...props }: MessageResponseProps) => (
|
||||
<Streamdown
|
||||
<HubStreamdown
|
||||
className={cn(
|
||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
className,
|
||||
)}
|
||||
plugins={streamdownPlugins}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.children === nextProps.children &&
|
||||
nextProps.isAnimating === prevProps.isAnimating,
|
||||
);
|
||||
|
||||
MessageResponse.displayName = "MessageResponse";
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import { cjk } from "@streamdown/cjk";
|
||||
import { code } from "@streamdown/code";
|
||||
import { math } from "@streamdown/math";
|
||||
import { mermaid } from "@streamdown/mermaid";
|
||||
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import {
|
||||
@@ -17,7 +13,6 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
@@ -26,6 +21,7 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Shimmer } from "./shimmer";
|
||||
import { HubStreamdown } from "./streamdown";
|
||||
|
||||
interface ReasoningContextValue {
|
||||
isStreaming: boolean;
|
||||
@@ -204,8 +200,6 @@ export type ReasoningContentProps = ComponentProps<
|
||||
children: string;
|
||||
};
|
||||
|
||||
const streamdownPlugins = { cjk, code, math, mermaid };
|
||||
|
||||
export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
@@ -216,7 +210,7 @@ export const ReasoningContent = memo(
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Streamdown plugins={streamdownPlugins}>{children}</Streamdown>
|
||||
<HubStreamdown>{children}</HubStreamdown>
|
||||
</CollapsibleContent>
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { cjk } from "@streamdown/cjk";
|
||||
import type { MermaidConfig } from "mermaid";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { isValidElement, memo } from "react";
|
||||
import {
|
||||
type Components,
|
||||
type DiagramPlugin,
|
||||
Streamdown,
|
||||
type StreamdownProps,
|
||||
} from "streamdown";
|
||||
import {
|
||||
CodeBlock,
|
||||
CodeBlockActions,
|
||||
CodeBlockCopyButton,
|
||||
CodeBlockFilename,
|
||||
CodeBlockHeader,
|
||||
CodeBlockTitle,
|
||||
} from "@/components/ai-elements/code-block";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type MarkdownCodeProps = ComponentProps<"code"> & {
|
||||
"data-block"?: boolean | string;
|
||||
node?: {
|
||||
properties?: {
|
||||
metastring?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const LANGUAGE_CLASS_PATTERN = /(?:^|\s)language-([^\s]+)/;
|
||||
const START_LINE_PATTERN = /startLine=(\d+)/;
|
||||
const NO_LINE_NUMBERS_PATTERN = /\bnoLineNumbers\b/;
|
||||
|
||||
function codeText(children: ReactNode): string {
|
||||
if (typeof children === "string" || typeof children === "number") {
|
||||
return String(children);
|
||||
}
|
||||
if (Array.isArray(children)) {
|
||||
return children.map(codeText).join("");
|
||||
}
|
||||
if (isValidElement<{ children?: ReactNode }>(children)) {
|
||||
return codeText(children.props.children);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
const MarkdownCode = ({
|
||||
children,
|
||||
className,
|
||||
node,
|
||||
"data-block": dataBlock,
|
||||
...props
|
||||
}: MarkdownCodeProps) => {
|
||||
const language = className?.match(LANGUAGE_CLASS_PATTERN)?.[1] ?? "text";
|
||||
|
||||
if (!dataBlock) {
|
||||
return (
|
||||
<code
|
||||
className={cn(
|
||||
"rounded bg-muted px-1.5 py-0.5 font-mono text-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = node?.properties?.metastring;
|
||||
const startLineMatch = meta?.match(START_LINE_PATTERN);
|
||||
const startLine = startLineMatch ? Number.parseInt(startLineMatch[1], 10) : 1;
|
||||
const showLineNumbers = meta ? !NO_LINE_NUMBERS_PATTERN.test(meta) : true;
|
||||
|
||||
return (
|
||||
<CodeBlock
|
||||
code={codeText(children)}
|
||||
data-start-line={startLine > 1 ? startLine : undefined}
|
||||
language={language}
|
||||
showLineNumbers={showLineNumbers}
|
||||
>
|
||||
<CodeBlockHeader>
|
||||
<CodeBlockTitle>
|
||||
<CodeBlockFilename>{language}</CodeBlockFilename>
|
||||
</CodeBlockTitle>
|
||||
<CodeBlockActions>
|
||||
<CodeBlockCopyButton />
|
||||
</CodeBlockActions>
|
||||
</CodeBlockHeader>
|
||||
</CodeBlock>
|
||||
);
|
||||
};
|
||||
|
||||
const markdownComponents = {
|
||||
code: MarkdownCode,
|
||||
} satisfies Components;
|
||||
|
||||
const DEFAULT_MERMAID_CONFIG = {
|
||||
fontFamily: "monospace",
|
||||
securityLevel: "strict",
|
||||
startOnLoad: false,
|
||||
suppressErrorRendering: true,
|
||||
theme: "default",
|
||||
} satisfies MermaidConfig;
|
||||
|
||||
interface LazyMermaidInstance {
|
||||
initialize: (config: MermaidConfig) => void;
|
||||
render: (
|
||||
id: string,
|
||||
source: string,
|
||||
) => Promise<{
|
||||
svg: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function createLazyMermaidPlugin(): DiagramPlugin {
|
||||
let config: MermaidConfig = DEFAULT_MERMAID_CONFIG;
|
||||
let initialized = false;
|
||||
|
||||
const instance: LazyMermaidInstance = {
|
||||
initialize(nextConfig: MermaidConfig) {
|
||||
config = { ...DEFAULT_MERMAID_CONFIG, ...config, ...nextConfig };
|
||||
initialized = false;
|
||||
},
|
||||
async render(id: string, source: string) {
|
||||
const mermaidModule = await import("mermaid");
|
||||
const mermaid = mermaidModule.default;
|
||||
if (!initialized) {
|
||||
mermaid.initialize(config);
|
||||
initialized = true;
|
||||
}
|
||||
return mermaid.render(id, source);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
getMermaid(nextConfig?: MermaidConfig) {
|
||||
if (nextConfig) {
|
||||
instance.initialize(nextConfig);
|
||||
}
|
||||
return instance;
|
||||
},
|
||||
language: "mermaid",
|
||||
name: "mermaid",
|
||||
type: "diagram",
|
||||
};
|
||||
}
|
||||
|
||||
const streamdownPlugins = { cjk, mermaid: createLazyMermaidPlugin() };
|
||||
|
||||
export type HubStreamdownProps = StreamdownProps;
|
||||
|
||||
export const HubStreamdown = memo(
|
||||
({ className, components, ...props }: HubStreamdownProps) => {
|
||||
const mergedComponents = components
|
||||
? { ...markdownComponents, ...components }
|
||||
: markdownComponents;
|
||||
|
||||
return (
|
||||
<Streamdown
|
||||
className={className}
|
||||
components={mergedComponents}
|
||||
plugins={streamdownPlugins}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
HubStreamdown.displayName = "HubStreamdown";
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Puzzle,
|
||||
Search,
|
||||
Server,
|
||||
Star,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
type MarketplacePrimitiveType,
|
||||
type MarketplaceTag,
|
||||
} from "@/lib/marketplace";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CommandBadge, PageFrame, PageHeader } from "./page-layout";
|
||||
|
||||
type EntryActionState =
|
||||
@@ -114,6 +116,13 @@ function entryKey(entry: Pick<MarketplaceEntry, "id" | "type">): string {
|
||||
return `${entry.type}:${entry.id}`;
|
||||
}
|
||||
|
||||
function compareFeaturedEntries(
|
||||
left: MarketplaceEntry,
|
||||
right: MarketplaceEntry,
|
||||
): number {
|
||||
return Number(Boolean(right.featured)) - Number(Boolean(left.featured));
|
||||
}
|
||||
|
||||
function normalizeMatchValue(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
@@ -257,6 +266,8 @@ function MarketplaceEntryCard({
|
||||
onToggleExpanded,
|
||||
onUninstall,
|
||||
matchedLocalItems = [],
|
||||
showFeatured = true,
|
||||
showTags = true,
|
||||
sourceLabel,
|
||||
tagLabels,
|
||||
}: {
|
||||
@@ -269,6 +280,8 @@ function MarketplaceEntryCard({
|
||||
onToggleExpanded: (entry: MarketplaceEntry) => void;
|
||||
onUninstall: (entry: MarketplaceEntry) => void;
|
||||
matchedLocalItems?: MarketplaceLocalInstalledItem[];
|
||||
showFeatured?: boolean;
|
||||
showTags?: boolean;
|
||||
sourceLabel?: string;
|
||||
tagLabels: Map<string, string>;
|
||||
}) {
|
||||
@@ -299,15 +312,63 @@ function MarketplaceEntryCard({
|
||||
: installed
|
||||
? "Uninstall"
|
||||
: "Install";
|
||||
const statusMessage = inlineMessage ? (
|
||||
<output
|
||||
className={cn(
|
||||
"text-xs",
|
||||
actionState?.status === "failed"
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{inlineMessage}
|
||||
</output>
|
||||
) : setupNeeded ? (
|
||||
<span className="text-xs text-amber-700 dark:text-amber-300">
|
||||
Requires setup after install
|
||||
</span>
|
||||
) : null;
|
||||
const actionButton = (
|
||||
<Button
|
||||
disabled={!installedStatusReady || busy}
|
||||
onClick={handleActionClick}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={installed ? "destructive" : "default"}
|
||||
>
|
||||
{busy || !installedStatusReady ? <Spinner /> : null}
|
||||
{installed && !busy ? <Trash2 className="size-4" /> : null}
|
||||
{actionLabel}
|
||||
</Button>
|
||||
);
|
||||
const content = (
|
||||
<>
|
||||
{!installed ? (
|
||||
<div
|
||||
className="absolute top-4 right-4"
|
||||
data-marketplace-entry-interactive
|
||||
>
|
||||
{actionButton}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 items-center gap-2",
|
||||
!installed && "pr-24",
|
||||
)}
|
||||
>
|
||||
<EntryIcon className="h-4 w-4 shrink-0 text-primary" />
|
||||
<h2 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">
|
||||
<h2 className="min-w-0 truncate text-sm font-semibold text-foreground">
|
||||
{entry.name}
|
||||
</h2>
|
||||
{showFeatured && entry.featured ? (
|
||||
<Badge className="border border-violet-500/20 bg-violet-500/10 text-violet-700 dark:text-violet-300">
|
||||
<Star className="fill-current" />
|
||||
Featured
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
{sourceLabel ? (
|
||||
@@ -349,23 +410,34 @@ function MarketplaceEntryCard({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||
{entry.description}
|
||||
</p>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-stretch sm:justify-between">
|
||||
<div className="grid min-w-0 flex-1 gap-2">
|
||||
{showTags && entry.tags.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{entry.tags.slice(0, 5).map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="outline"
|
||||
className="max-w-full text-muted-foreground"
|
||||
>
|
||||
<span className="truncate">{tagLabels.get(tag) ?? tag}</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{entry.tags.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{entry.tags.slice(0, 5).map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="outline"
|
||||
className="max-w-full text-muted-foreground"
|
||||
>
|
||||
<span className="truncate">{tagLabels.get(tag) ?? tag}</span>
|
||||
</Badge>
|
||||
))}
|
||||
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||
{entry.description}
|
||||
</p>
|
||||
{statusMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{installed ? (
|
||||
<div className="flex shrink-0 flex-col items-start gap-1 sm:items-end sm:justify-end">
|
||||
{actionButton}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{matchedLocalItems.some((item) => item.renderMatchedDetails) ? (
|
||||
<div className="grid gap-2" data-marketplace-entry-details>
|
||||
@@ -377,36 +449,6 @@ function MarketplaceEntryCard({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-h-5 text-xs text-muted-foreground">
|
||||
{inlineMessage ? (
|
||||
<output
|
||||
className={
|
||||
actionState?.status === "failed"
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{inlineMessage}
|
||||
</output>
|
||||
) : setupNeeded ? (
|
||||
<span className="text-amber-700 dark:text-amber-300">
|
||||
Requires setup after install
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
disabled={!installedStatusReady || busy}
|
||||
onClick={handleActionClick}
|
||||
type="button"
|
||||
variant={installed ? "destructive" : "default"}
|
||||
>
|
||||
{busy || !installedStatusReady ? <Spinner /> : null}
|
||||
{installed && !busy ? <Trash2 className="size-4" /> : null}
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{expanded && hasExpandableDetails ? (
|
||||
<EntryDetails actionState={actionState} entry={entry} />
|
||||
) : null}
|
||||
@@ -415,7 +457,7 @@ function MarketplaceEntryCard({
|
||||
|
||||
if (!hasExpandableDetails) {
|
||||
return (
|
||||
<div className="grid gap-3 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20">
|
||||
<div className="relative grid gap-2 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
@@ -426,7 +468,7 @@ function MarketplaceEntryCard({
|
||||
<div
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${expanded ? "Collapse" : "Expand"} ${entry.name}`}
|
||||
className="grid cursor-pointer gap-3 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
|
||||
className="relative grid cursor-pointer gap-2 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
|
||||
onClick={(event) => {
|
||||
if (
|
||||
event.target instanceof HTMLElement &&
|
||||
@@ -487,6 +529,7 @@ function MarketplaceSection({
|
||||
emptyMessage,
|
||||
entries,
|
||||
expandedEntryKey,
|
||||
headerContent,
|
||||
installedEntryKeys,
|
||||
installedStatusReady,
|
||||
localOnlyInstalledItems = [],
|
||||
@@ -494,6 +537,8 @@ function MarketplaceSection({
|
||||
onInstall,
|
||||
onToggleExpanded,
|
||||
onUninstall,
|
||||
showFeaturedBadges = true,
|
||||
showEntryTags = true,
|
||||
sourceLabel,
|
||||
tagLabels,
|
||||
title,
|
||||
@@ -502,6 +547,7 @@ function MarketplaceSection({
|
||||
emptyMessage: string;
|
||||
entries: MarketplaceEntry[];
|
||||
expandedEntryKey: string | null;
|
||||
headerContent?: ReactNode;
|
||||
installedEntryKeys: Set<string>;
|
||||
installedStatusReady: boolean;
|
||||
localOnlyInstalledItems?: MarketplaceLocalInstalledItem[];
|
||||
@@ -509,6 +555,8 @@ function MarketplaceSection({
|
||||
onInstall: (entry: MarketplaceEntry) => void;
|
||||
onToggleExpanded: (entry: MarketplaceEntry) => void;
|
||||
onUninstall: (entry: MarketplaceEntry) => void;
|
||||
showFeaturedBadges?: boolean;
|
||||
showEntryTags?: boolean;
|
||||
sourceLabel?: string;
|
||||
tagLabels: Map<string, string>;
|
||||
title: string;
|
||||
@@ -520,6 +568,7 @@ function MarketplaceSection({
|
||||
<h2 className="text-base font-semibold text-foreground">{title}</h2>
|
||||
<span className="text-sm text-muted-foreground">{totalCount}</span>
|
||||
</div>
|
||||
{headerContent}
|
||||
{totalCount > 0 ? (
|
||||
<div className="grid gap-3">
|
||||
{localOnlyInstalledItems.map((item) => item.render())}
|
||||
@@ -537,6 +586,8 @@ function MarketplaceSection({
|
||||
onToggleExpanded={onToggleExpanded}
|
||||
onUninstall={onUninstall}
|
||||
matchedLocalItems={matchedLocalItemsByEntryKey?.get(key) ?? []}
|
||||
showFeatured={showFeaturedBadges}
|
||||
showTags={showEntryTags}
|
||||
sourceLabel={sourceLabel}
|
||||
tagLabels={tagLabels}
|
||||
/>
|
||||
@@ -626,19 +677,48 @@ export function MarketplaceView({
|
||||
);
|
||||
|
||||
const primitiveEntries = useMemo(
|
||||
() => catalog?.entries.filter((entry) => entry.type === primitive) ?? [],
|
||||
() =>
|
||||
(catalog?.entries.filter((entry) => entry.type === primitive) ?? []).sort(
|
||||
compareFeaturedEntries,
|
||||
),
|
||||
[catalog?.entries, primitive],
|
||||
);
|
||||
|
||||
const queryFilteredEntries = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
return primitiveEntries.filter((entry) => {
|
||||
return (
|
||||
normalizedQuery.length === 0 ||
|
||||
entrySearchText(entry, tagLabels).includes(normalizedQuery)
|
||||
);
|
||||
});
|
||||
}, [primitiveEntries, query, tagLabels]);
|
||||
|
||||
const installedEntries = useMemo(
|
||||
() =>
|
||||
queryFilteredEntries.filter((entry) =>
|
||||
installedEntryKeys.has(entryKey(entry)),
|
||||
),
|
||||
[queryFilteredEntries, installedEntryKeys],
|
||||
);
|
||||
|
||||
const marketplaceEntriesBeforeTag = useMemo(
|
||||
() =>
|
||||
queryFilteredEntries.filter(
|
||||
(entry) => !installedEntryKeys.has(entryKey(entry)),
|
||||
),
|
||||
[queryFilteredEntries, installedEntryKeys],
|
||||
);
|
||||
|
||||
const tagCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const entry of primitiveEntries) {
|
||||
for (const entry of marketplaceEntriesBeforeTag) {
|
||||
for (const tag of entry.tags) {
|
||||
counts.set(tag, (counts.get(tag) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}, [primitiveEntries]);
|
||||
}, [marketplaceEntriesBeforeTag]);
|
||||
|
||||
const primitiveTags = useMemo(
|
||||
() =>
|
||||
@@ -646,26 +726,20 @@ export function MarketplaceView({
|
||||
[catalog?.tags, tagCounts],
|
||||
);
|
||||
|
||||
const filteredEntries = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
return primitiveEntries.filter((entry) => {
|
||||
const matchesTag = !selectedTag || entry.tags.includes(selectedTag);
|
||||
const matchesQuery =
|
||||
normalizedQuery.length === 0 ||
|
||||
entrySearchText(entry, tagLabels).includes(normalizedQuery);
|
||||
return matchesTag && matchesQuery;
|
||||
});
|
||||
}, [primitiveEntries, query, selectedTag, tagLabels]);
|
||||
const catalogEntries = useMemo(
|
||||
() =>
|
||||
marketplaceEntriesBeforeTag.filter(
|
||||
(entry) => !selectedTag || entry.tags.includes(selectedTag),
|
||||
),
|
||||
[marketplaceEntriesBeforeTag, selectedTag],
|
||||
);
|
||||
|
||||
const matchedLocalItemsByEntryKey = useMemo(() => {
|
||||
const matched = new Map<string, MarketplaceLocalInstalledItem[]>();
|
||||
for (const item of installedItems ?? []) {
|
||||
for (const entry of filteredEntries) {
|
||||
for (const entry of installedEntries) {
|
||||
const key = entryKey(entry);
|
||||
if (
|
||||
!installedEntryKeys.has(key) ||
|
||||
!entryMatchesLocalItem(entry, item)
|
||||
) {
|
||||
if (!entryMatchesLocalItem(entry, item)) {
|
||||
continue;
|
||||
}
|
||||
const items = matched.get(key) ?? [];
|
||||
@@ -674,49 +748,78 @@ export function MarketplaceView({
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}, [filteredEntries, installedEntryKeys, installedItems]);
|
||||
}, [installedEntries, installedItems]);
|
||||
|
||||
const matchedLocalItemKeys = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
[...matchedLocalItemsByEntryKey.values()].flatMap((items) =>
|
||||
items.map((item) => item.key),
|
||||
),
|
||||
),
|
||||
[matchedLocalItemsByEntryKey],
|
||||
);
|
||||
const matchedLocalItemKeys = useMemo(() => {
|
||||
const matched = new Set<string>();
|
||||
const installedMarketplaceEntries = primitiveEntries.filter((entry) =>
|
||||
installedEntryKeys.has(entryKey(entry)),
|
||||
);
|
||||
for (const item of installedItems ?? []) {
|
||||
if (
|
||||
installedMarketplaceEntries.some((entry) =>
|
||||
entryMatchesLocalItem(entry, item),
|
||||
)
|
||||
) {
|
||||
matched.add(item.key);
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}, [installedEntryKeys, installedItems, primitiveEntries]);
|
||||
|
||||
const localOnlyInstalledItems = useMemo(
|
||||
() =>
|
||||
(installedItems ?? []).filter(
|
||||
(item) => !matchedLocalItemKeys.has(item.key),
|
||||
),
|
||||
[installedItems, matchedLocalItemKeys],
|
||||
);
|
||||
const localOnlyInstalledItems = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
return (installedItems ?? []).filter((item) => {
|
||||
if (matchedLocalItemKeys.has(item.key)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
normalizedQuery.length === 0 ||
|
||||
item.matchValues
|
||||
.map(normalizeMatchValue)
|
||||
.some((value) => value.includes(normalizedQuery))
|
||||
);
|
||||
});
|
||||
}, [installedItems, matchedLocalItemKeys, query]);
|
||||
|
||||
const installedEntries = useMemo(
|
||||
() =>
|
||||
filteredEntries.filter((entry) =>
|
||||
installedEntryKeys.has(entryKey(entry)),
|
||||
),
|
||||
[filteredEntries, installedEntryKeys],
|
||||
);
|
||||
|
||||
const catalogEntries = useMemo(
|
||||
() =>
|
||||
filteredEntries.filter(
|
||||
(entry) => !installedEntryKeys.has(entryKey(entry)),
|
||||
),
|
||||
[filteredEntries, installedEntryKeys],
|
||||
);
|
||||
|
||||
const activeFilters = query.trim().length > 0 || selectedTag !== null;
|
||||
const installedStatusReady = installedStatusState === "ready";
|
||||
|
||||
const clearFilters = () => {
|
||||
setQuery("");
|
||||
setSelectedTag(null);
|
||||
};
|
||||
const marketplaceTagFilters =
|
||||
primitiveTags.length > 0 ? (
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{primitiveTags.map((tag) => (
|
||||
<TagButton
|
||||
active={selectedTag === tag.id}
|
||||
count={tagCounts.get(tag.id) ?? 0}
|
||||
key={tag.id}
|
||||
onClick={() =>
|
||||
setSelectedTag((current) =>
|
||||
current === tag.id ? null : tag.id,
|
||||
)
|
||||
}
|
||||
tag={tag}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex min-h-8 shrink-0 items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{catalogEntries.length}
|
||||
</span>
|
||||
<span>{catalogEntries.length === 1 ? "result" : "results"}</span>
|
||||
{selectedTag ? (
|
||||
<Button
|
||||
onClick={() => setSelectedTag(null)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const setEntryState = (entry: MarketplaceEntry, state: EntryActionState) => {
|
||||
const key = entryKey(entry);
|
||||
@@ -863,43 +966,7 @@ export function MarketplaceView({
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-h-8 items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{filteredEntries.length}
|
||||
</span>
|
||||
<span>
|
||||
{filteredEntries.length === 1 ? "result" : "results"}
|
||||
</span>
|
||||
{activeFilters ? (
|
||||
<Button
|
||||
onClick={clearFilters}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{primitiveTags.length > 0 ? (
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{primitiveTags.map((tag) => (
|
||||
<TagButton
|
||||
active={selectedTag === tag.id}
|
||||
count={tagCounts.get(tag.id) ?? 0}
|
||||
key={tag.id}
|
||||
onClick={() =>
|
||||
setSelectedTag((current) =>
|
||||
current === tag.id ? null : tag.id,
|
||||
)
|
||||
}
|
||||
tag={tag}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<MarketplaceSection
|
||||
@@ -914,6 +981,8 @@ export function MarketplaceView({
|
||||
onInstall={installEntry}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
onUninstall={uninstallEntry}
|
||||
showFeaturedBadges={false}
|
||||
showEntryTags={false}
|
||||
sourceLabel="Marketplace"
|
||||
tagLabels={tagLabels}
|
||||
title="Installed"
|
||||
@@ -924,6 +993,7 @@ export function MarketplaceView({
|
||||
emptyMessage={pageDetails.emptyCatalog}
|
||||
entries={catalogEntries}
|
||||
expandedEntryKey={expandedEntryKey}
|
||||
headerContent={marketplaceTagFilters}
|
||||
installedEntryKeys={installedEntryKeys}
|
||||
installedStatusReady={installedStatusReady}
|
||||
onInstall={installEntry}
|
||||
|
||||
@@ -39,10 +39,10 @@ export type CustomizationSection =
|
||||
const sectionDescriptions: Record<CustomizationSection, string> = {
|
||||
Rules: "Review project and global rule files that shape Cline behavior.",
|
||||
Hooks: "Inspect hook configuration and recent execution status.",
|
||||
MCP: "Manage installed MCP servers and add new servers from the catalog.",
|
||||
Skills: "Manage installed skills and add new skills from the catalog.",
|
||||
MCP: "Manage installed MCP servers and add new servers from the marketplace.",
|
||||
Skills: "Manage installed skills and add new skills from the marketplace.",
|
||||
Agents: "Review configured agents discovered from local settings.",
|
||||
Plugins: "Manage installed plugins and add new plugins from the catalog.",
|
||||
Plugins: "Manage installed plugins and add new plugins from the marketplace.",
|
||||
Tools: "Inspect built-in tools and tools contributed by plugins.",
|
||||
};
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export type MarketplaceEntry = {
|
||||
id: string;
|
||||
type: MarketplacePrimitiveType;
|
||||
name: string;
|
||||
featured?: boolean;
|
||||
tagline: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
@@ -100,7 +101,7 @@ export async function fetchMarketplaceCatalog(): Promise<MarketplaceCatalog> {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch marketplace catalog: ${response.status}`);
|
||||
throw new Error(`Failed to fetch marketplace: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
const baseUrl = typeof data?.baseUrl === "string" ? data.baseUrl : undefined;
|
||||
@@ -152,6 +153,10 @@ export async function fetchMarketplaceCatalog(): Promise<MarketplaceCatalog> {
|
||||
id: candidate.id,
|
||||
type: candidate.type,
|
||||
name: candidate.name,
|
||||
featured:
|
||||
typeof candidate.featured === "boolean"
|
||||
? candidate.featured
|
||||
: undefined,
|
||||
tagline: candidate.tagline,
|
||||
description: candidate.description,
|
||||
tags: toStringArray(candidate.tags),
|
||||
|
||||
@@ -3,6 +3,27 @@ import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
const mermaidChunkGroups = [
|
||||
{
|
||||
name: "mermaid-parser",
|
||||
maxSize: 450_000,
|
||||
test: /node_modules[\\/](?:\.bun[\\/])?@mermaid-js[+]parser/,
|
||||
},
|
||||
{
|
||||
name: "mermaid-langium",
|
||||
test: /node_modules[\\/](?:\.bun[\\/])?langium/,
|
||||
},
|
||||
{
|
||||
name: "mermaid-layout",
|
||||
maxSize: 450_000,
|
||||
test: /node_modules[\\/](?:\.bun[\\/])?(?:cytoscape|cytoscape-cose-bilkent|dagre|elkjs)/,
|
||||
},
|
||||
{
|
||||
name: "mermaid-markup",
|
||||
test: /node_modules[\\/](?:\.bun[\\/])?(?:katex|dompurify)/,
|
||||
},
|
||||
];
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
@@ -25,5 +46,13 @@ export default defineConfig({
|
||||
outDir: "../../dist/webview",
|
||||
emptyOutDir: true,
|
||||
cssMinify: "esbuild",
|
||||
chunkSizeWarningLimit: 600,
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
codeSplitting: {
|
||||
groups: mermaidChunkGroups,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
<div align="center"><sub>
|
||||
English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md" target="_blank">Español</a> | <a href="https://github.com/cline/cline/blob/main/locales/de/README.md" target="_blank">Deutsch</a> | <a href="https://github.com/cline/cline/blob/main/locales/ja/README.md" target="_blank">日本語</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-cn/README.md" target="_blank">简体中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-tw/README.md" target="_blank">繁體中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/ko/README.md" target="_blank">한국어</a>
|
||||
</sub></div>
|
||||
|
||||
# Cline
|
||||
<div align="center">
|
||||
<table>
|
||||
|
||||
@@ -126,6 +126,9 @@ const copyWasmFiles = {
|
||||
const buildEnvVars = {
|
||||
"import.meta.url": "_importMetaUrl",
|
||||
"process.env.IS_STANDALONE": JSON.stringify(standalone ? "true" : "false"),
|
||||
// Always inline these values so ordinary builds cannot be mislabeled by a
|
||||
// user's runtime environment. Only the combined rollout workflow sets them.
|
||||
"process.env.CLINE_ROLLOUT_VARIANT": JSON.stringify(process.env.CLINE_ROLLOUT_VARIANT || ""),
|
||||
}
|
||||
|
||||
if (production) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "claude-dev",
|
||||
"displayName": "Cline",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "3.89.2",
|
||||
"version": "4.0.11",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
|
||||
@@ -331,6 +331,10 @@ message ModelsApiOptions {
|
||||
optional OpenRouterModelInfo act_mode_cline_model_info = 236;
|
||||
optional string act_mode_cline_pass_model_id = 237;
|
||||
optional OpenRouterModelInfo act_mode_cline_pass_model_info = 238;
|
||||
optional bool plan_mode_vertex_custom_model_selected = 239;
|
||||
optional OpenRouterModelInfo plan_mode_vertex_custom_model_info = 240;
|
||||
optional bool act_mode_vertex_custom_model_selected = 241;
|
||||
optional OpenRouterModelInfo act_mode_vertex_custom_model_info = 242;
|
||||
}
|
||||
|
||||
// Request for updating API configuration (legacy - uses combined configuration)
|
||||
@@ -698,4 +702,8 @@ message ModelsApiConfiguration {
|
||||
optional OpenRouterModelInfo act_mode_cline_model_info = 241;
|
||||
optional string act_mode_cline_pass_model_id = 242;
|
||||
optional OpenRouterModelInfo act_mode_cline_pass_model_info = 243;
|
||||
optional bool plan_mode_vertex_custom_model_selected = 244;
|
||||
optional OpenRouterModelInfo plan_mode_vertex_custom_model_info = 245;
|
||||
optional bool act_mode_vertex_custom_model_selected = 246;
|
||||
optional OpenRouterModelInfo act_mode_vertex_custom_model_info = 247;
|
||||
}
|
||||
|
||||
@@ -287,6 +287,10 @@ message Settings {
|
||||
optional OpenRouterModelInfo act_mode_cline_model_info = 181;
|
||||
optional bool show_feature_tips = 182;
|
||||
optional bool lazy_teammate_mode_enabled = 183;
|
||||
optional bool plan_mode_vertex_custom_model_selected = 184;
|
||||
optional OpenRouterModelInfo plan_mode_vertex_custom_model_info = 185;
|
||||
optional bool act_mode_vertex_custom_model_selected = 186;
|
||||
optional OpenRouterModelInfo act_mode_vertex_custom_model_info = 187;
|
||||
}
|
||||
|
||||
message State {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import "should"
|
||||
import {
|
||||
type ApiConfiguration,
|
||||
clinePassDefaultModelId,
|
||||
clinePassModelInfoSaneDefaults,
|
||||
clinePassModels,
|
||||
type ModelInfo,
|
||||
} from "@shared/api"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import sinon from "sinon"
|
||||
import { ClineAccountService } from "@/services/account/ClineAccountService"
|
||||
import { AuthService } from "@/services/auth/AuthService"
|
||||
import { buildApiHandler } from "../index"
|
||||
|
||||
describe("buildApiHandler", () => {
|
||||
beforeEach(() => {
|
||||
sinon.stub(ClineAccountService, "getInstance").returns({} as any)
|
||||
sinon.stub(AuthService, "getInstance").returns({} as any)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
const buildClinePassHandler = (configuration: Partial<ApiConfiguration>, mode: Mode = "act") =>
|
||||
buildApiHandler(
|
||||
{
|
||||
planModeApiProvider: "cline-pass",
|
||||
actModeApiProvider: "cline-pass",
|
||||
...configuration,
|
||||
} as ApiConfiguration,
|
||||
mode,
|
||||
)
|
||||
|
||||
describe("cline-pass provider", () => {
|
||||
const freeModelInfo: ModelInfo = {
|
||||
...clinePassModelInfoSaneDefaults,
|
||||
maxTokens: 32_768,
|
||||
contextWindow: 256_000,
|
||||
description: "A free model",
|
||||
}
|
||||
|
||||
it("passes a free (non cline-pass prefixed) model id through with its stored info", () => {
|
||||
const handler = buildClinePassHandler({
|
||||
actModeClinePassModelId: "kwaipilot/kat-coder-pro",
|
||||
actModeClinePassModelInfo: freeModelInfo,
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("kwaipilot/kat-coder-pro")
|
||||
model.info.should.deepEqual(freeModelInfo)
|
||||
})
|
||||
|
||||
it("passes a :free suffixed model id through with its stored info", () => {
|
||||
const handler = buildClinePassHandler({
|
||||
actModeClinePassModelId: "arcee-ai/trinity-large-preview:free",
|
||||
actModeClinePassModelInfo: freeModelInfo,
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("arcee-ai/trinity-large-preview:free")
|
||||
model.info.should.deepEqual(freeModelInfo)
|
||||
})
|
||||
|
||||
it("falls back to sane defaults for a free model id without stored info", () => {
|
||||
const handler = buildClinePassHandler({
|
||||
actModeClinePassModelId: "kwaipilot/kat-coder-pro",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("kwaipilot/kat-coder-pro")
|
||||
model.info.should.deepEqual(clinePassModelInfoSaneDefaults)
|
||||
})
|
||||
|
||||
it("resolves cline-pass ids against the static model table", () => {
|
||||
const handler = buildClinePassHandler({
|
||||
actModeClinePassModelId: "cline-pass/glm-5.2",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("cline-pass/glm-5.2")
|
||||
model.info.should.deepEqual(clinePassModels["cline-pass/glm-5.2"])
|
||||
})
|
||||
|
||||
it("falls back to the default pass model when no model id is configured", () => {
|
||||
const handler = buildClinePassHandler({})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal(clinePassDefaultModelId)
|
||||
model.info.should.deepEqual(clinePassModels[clinePassDefaultModelId])
|
||||
})
|
||||
|
||||
it("resolves plan and act mode model ids independently", () => {
|
||||
const configuration: Partial<ApiConfiguration> = {
|
||||
planModeClinePassModelId: "kwaipilot/kat-coder-pro",
|
||||
planModeClinePassModelInfo: freeModelInfo,
|
||||
actModeClinePassModelId: "cline-pass/glm-5.2",
|
||||
}
|
||||
|
||||
buildClinePassHandler(configuration, "plan").getModel().id.should.equal("kwaipilot/kat-coder-pro")
|
||||
buildClinePassHandler(configuration, "act").getModel().id.should.equal("cline-pass/glm-5.2")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,13 @@
|
||||
import { ApiConfiguration, clinePassDefaultModelId, ModelInfo, QwenApiRegions, resolveClinePassModelInfo } from "@shared/api"
|
||||
import {
|
||||
ApiConfiguration,
|
||||
buildModelInfoNameMap,
|
||||
clinePassDefaultModelId,
|
||||
ModelInfo,
|
||||
QwenApiRegions,
|
||||
resolveClinePassModelInfo,
|
||||
} from "@shared/api"
|
||||
import { Mode } from "@shared/storage/types"
|
||||
import { featureFlagsService } from "@/services/feature-flags"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { ClineTool } from "@/shared/tools"
|
||||
import { AIhubmixHandler } from "./providers/aihubmix"
|
||||
@@ -80,10 +85,7 @@ function createHandlerForProvider(
|
||||
options: Omit<ApiConfiguration, "apiProvider">,
|
||||
mode: Mode,
|
||||
): ApiHandler {
|
||||
const effectiveApiProvider =
|
||||
apiProvider === "cline-pass" && !featureFlagsService.getBooleanFlagEnabled(FeatureFlag.CLINE_PASS) ? "cline" : apiProvider
|
||||
|
||||
switch (effectiveApiProvider) {
|
||||
switch (apiProvider) {
|
||||
case "anthropic":
|
||||
return new AnthropicHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
@@ -136,6 +138,8 @@ function createHandlerForProvider(
|
||||
vertexProjectId: options.vertexProjectId,
|
||||
vertexRegion: options.vertexRegion,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
vertexCustomModelInfo:
|
||||
mode === "plan" ? options.planModeVertexCustomModelInfo : options.actModeVertexCustomModelInfo,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
geminiApiKey: options.geminiApiKey,
|
||||
@@ -204,6 +208,7 @@ function createHandlerForProvider(
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
deepSeekApiKey: options.deepSeekApiKey,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
})
|
||||
case "requesty":
|
||||
return new RequestyHandler({
|
||||
@@ -289,10 +294,15 @@ function createHandlerForProvider(
|
||||
mode === "plan" ? options.planModeClinePassModelId : options.actModeClinePassModelId
|
||||
const configuredClinePassModelInfo =
|
||||
mode === "plan" ? options.planModeClinePassModelInfo : options.actModeClinePassModelInfo
|
||||
const clineModelId = configuredClinePassModelId?.startsWith("cline-pass/")
|
||||
? configuredClinePassModelId
|
||||
: clinePassDefaultModelId
|
||||
const clineModelInfo = configuredClinePassModelInfo || resolveClinePassModelInfo(clineModelId)
|
||||
// ClinePass users may also select Cline free models (OpenRouter-style ids without
|
||||
// the cline-pass/ prefix), so pass any configured id through to the Cline API.
|
||||
const clineModelId = configuredClinePassModelId || clinePassDefaultModelId
|
||||
const clineModelInfo = resolveClinePassModelInfo(
|
||||
clineModelId,
|
||||
configuredClinePassModelInfo
|
||||
? buildModelInfoNameMap({ [clineModelId]: configuredClinePassModelInfo })
|
||||
: undefined,
|
||||
)
|
||||
return new ClineHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
clineAccountId: options.clineAccountId,
|
||||
|
||||
@@ -64,6 +64,30 @@ describe("AnthropicHandler", () => {
|
||||
result.info.should.deepEqual(anthropicModels["claude-opus-4-7:1m"])
|
||||
})
|
||||
|
||||
it("should return the Opus 5 model when configured", () => {
|
||||
const handler = new AnthropicHandler({
|
||||
apiKey: "test-api-key",
|
||||
apiModelId: "claude-opus-5",
|
||||
})
|
||||
|
||||
const result = handler.getModel()
|
||||
|
||||
result.id.should.equal("claude-opus-5")
|
||||
result.info.should.deepEqual(anthropicModels["claude-opus-5"])
|
||||
})
|
||||
|
||||
it("should return the Opus 5 1m model when configured", () => {
|
||||
const handler = new AnthropicHandler({
|
||||
apiKey: "test-api-key",
|
||||
apiModelId: "claude-opus-5:1m",
|
||||
})
|
||||
|
||||
const result = handler.getModel()
|
||||
|
||||
result.id.should.equal("claude-opus-5:1m")
|
||||
result.info.should.deepEqual(anthropicModels["claude-opus-5:1m"])
|
||||
})
|
||||
|
||||
it("should return the 4.8 model when configured", () => {
|
||||
const handler = new AnthropicHandler({
|
||||
apiKey: "test-api-key",
|
||||
|
||||
@@ -215,11 +215,21 @@ describe("AwsBedrockHandler", () => {
|
||||
bedrockModels["anthropic.claude-opus-4-8:1m"].supportsGlobalEndpoint.should.equal(true)
|
||||
})
|
||||
|
||||
it("should mark Bedrock Opus 5 variants as global-endpoint capable", () => {
|
||||
bedrockModels["anthropic.claude-opus-5"].supportsGlobalEndpoint.should.equal(true)
|
||||
bedrockModels["anthropic.claude-opus-5:1m"].supportsGlobalEndpoint.should.equal(true)
|
||||
})
|
||||
|
||||
it("should mark Bedrock Fable 5 variants as global-endpoint capable", () => {
|
||||
bedrockModels["anthropic.claude-fable-5"].supportsGlobalEndpoint.should.equal(true)
|
||||
bedrockModels["anthropic.claude-fable-5:1m"].supportsGlobalEndpoint.should.equal(true)
|
||||
})
|
||||
|
||||
it("should mark Bedrock Sonnet 5 variants as global-endpoint capable", () => {
|
||||
bedrockModels["anthropic.claude-sonnet-5"].supportsGlobalEndpoint.should.equal(true)
|
||||
bedrockModels["anthropic.claude-sonnet-5:1m"].supportsGlobalEndpoint.should.equal(true)
|
||||
})
|
||||
|
||||
it("should include Vertex Opus 4.7 variants in the derived global model list", () => {
|
||||
vertexModels["claude-opus-4-7"].supportsGlobalEndpoint.should.equal(true)
|
||||
vertexModels["claude-opus-4-7:1m"].supportsGlobalEndpoint.should.equal(true)
|
||||
@@ -234,6 +244,13 @@ describe("AwsBedrockHandler", () => {
|
||||
vertexGlobalModels.should.have.property("claude-opus-4-8:1m")
|
||||
})
|
||||
|
||||
it("should include Vertex Opus 5 variants in the derived global model list", () => {
|
||||
vertexModels["claude-opus-5"].supportsGlobalEndpoint.should.equal(true)
|
||||
vertexModels["claude-opus-5:1m"].supportsGlobalEndpoint.should.equal(true)
|
||||
vertexGlobalModels.should.have.property("claude-opus-5")
|
||||
vertexGlobalModels.should.have.property("claude-opus-5:1m")
|
||||
})
|
||||
|
||||
it("should include Vertex Fable 5 variants in the derived global model list", () => {
|
||||
vertexModels["claude-fable-5"].supportsGlobalEndpoint.should.equal(true)
|
||||
vertexModels["claude-fable-5:1m"].supportsGlobalEndpoint.should.equal(true)
|
||||
@@ -990,6 +1007,19 @@ describe("AwsBedrockHandler", () => {
|
||||
modelId.should.equal("jp.anthropic.claude-sonnet-4-6")
|
||||
})
|
||||
|
||||
it("should apply JP cross-region prefix for sonnet 5", async () => {
|
||||
const jpOptions: AwsBedrockHandlerOptions = {
|
||||
...mockOptions,
|
||||
awsUseCrossRegionInference: true,
|
||||
apiModelId: "anthropic.claude-sonnet-5",
|
||||
awsRegion: "ap-northeast-1",
|
||||
}
|
||||
const jpHandler = new AwsBedrockHandler(jpOptions)
|
||||
|
||||
const modelId = await jpHandler.getModelId()
|
||||
modelId.should.equal("jp.anthropic.claude-sonnet-5")
|
||||
})
|
||||
|
||||
it("should apply global cross-region prefix for supported models", async () => {
|
||||
const globalOptions: AwsBedrockHandlerOptions = {
|
||||
...mockOptions,
|
||||
|
||||
@@ -396,6 +396,26 @@ describe("ClaudeCodeHandler", () => {
|
||||
model.info.contextWindow.should.equal(1_000_000)
|
||||
})
|
||||
|
||||
it("should support Opus 5 model id", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "claude-opus-5",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("claude-opus-5")
|
||||
model.info.contextWindow.should.equal(200_000)
|
||||
})
|
||||
|
||||
it("should support Opus 5 1m model id", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "claude-opus-5[1m]",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("claude-opus-5[1m]")
|
||||
model.info.contextWindow.should.equal(1_000_000)
|
||||
})
|
||||
|
||||
it("should support Opus 4.8 model id", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "claude-opus-4-8",
|
||||
@@ -426,6 +446,26 @@ describe("ClaudeCodeHandler", () => {
|
||||
model.info.contextWindow.should.equal(200_000)
|
||||
})
|
||||
|
||||
it("should support Sonnet 5 model id", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "claude-sonnet-5",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("claude-sonnet-5")
|
||||
model.info.contextWindow.should.equal(200_000)
|
||||
})
|
||||
|
||||
it("should support Sonnet 5 1m model id", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "claude-sonnet-5[1m]",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("claude-sonnet-5[1m]")
|
||||
model.info.contextWindow.should.equal(1_000_000)
|
||||
})
|
||||
|
||||
it("should support Fable 5 1m model id", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "claude-fable-5[1m]",
|
||||
|
||||
@@ -180,7 +180,7 @@ describe("ClineHandler", () => {
|
||||
const fakeClient = { chat: { completions: { create: sinon.stub().rejects(apiError) } } }
|
||||
sinon.stub(handler as any, "ensureClient").resolves(fakeClient as any)
|
||||
sinon.stub(handler as any, "getFreeModelIdSet").resolves(new Set())
|
||||
sinon.stub(handler, "getModel").returns({ id: "cline-pass/glm-5.1", info: openRouterDefaultModelInfo })
|
||||
sinon.stub(handler, "getModel").returns({ id: "cline-pass/glm-5.2", info: openRouterDefaultModelInfo })
|
||||
|
||||
let thrown: unknown
|
||||
try {
|
||||
@@ -191,7 +191,7 @@ describe("ClineHandler", () => {
|
||||
thrown = e
|
||||
}
|
||||
|
||||
const clineError = ClineError.transform(thrown, "cline-pass/glm-5.1", "cline-pass")
|
||||
const clineError = ClineError.transform(thrown, "cline-pass/glm-5.2", "cline-pass")
|
||||
clineError.isErrorType(ClineErrorType.Entitlement).should.be.true()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,8 +6,10 @@ import { MoonshotHandler } from "../moonshot"
|
||||
|
||||
interface MoonshotRequestPayload {
|
||||
model: string
|
||||
temperature: number
|
||||
max_tokens: number
|
||||
messages?: Array<{ role: string; reasoning_content?: string }>
|
||||
temperature?: number
|
||||
max_tokens?: number
|
||||
max_completion_tokens?: number
|
||||
}
|
||||
|
||||
describe("MoonshotHandler", () => {
|
||||
@@ -47,7 +49,66 @@ describe("MoonshotHandler", () => {
|
||||
|
||||
const payload = createStub.firstCall.args[0] as MoonshotRequestPayload
|
||||
payload.model.should.equal("kimi-k2.6")
|
||||
payload.temperature.should.equal(moonshotModels["kimi-k2.6"].temperature)
|
||||
payload.max_tokens.should.equal(moonshotModels["kimi-k2.6"].maxTokens)
|
||||
should(payload.temperature).equal(moonshotModels["kimi-k2.6"].temperature)
|
||||
should(payload.max_tokens).equal(moonshotModels["kimi-k2.6"].maxTokens)
|
||||
})
|
||||
|
||||
it("supports kimi-k3 request requirements and scopes reasoning replay", async () => {
|
||||
const handler = new MoonshotHandler({
|
||||
moonshotApiKey: "test-api-key",
|
||||
apiModelId: "kimi-k3",
|
||||
})
|
||||
|
||||
const createStub = sinon.stub().resolves(createAsyncIterable([]))
|
||||
sinon.stub(handler as unknown as { ensureClient: () => unknown }, "ensureClient").returns({
|
||||
chat: {
|
||||
completions: {
|
||||
create: createStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const messages: ClineStorageMessage[] = [
|
||||
{ role: "user", content: "foreign-provider question" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "Foreign reasoning", signature: "" },
|
||||
{ type: "text", text: "Foreign answer" },
|
||||
],
|
||||
modelInfo: { providerId: "anthropic", modelId: "claude-sonnet-4", mode: "act" },
|
||||
},
|
||||
{ role: "user", content: "K3 question" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "K3 reasoning", signature: "" },
|
||||
{ type: "text", text: "K3 answer" },
|
||||
],
|
||||
modelInfo: { providerId: "moonshot", modelId: "kimi-k3", mode: "act" },
|
||||
},
|
||||
{ role: "user", content: "legacy question" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "Unattributed reasoning", signature: "" },
|
||||
{ type: "text", text: "Legacy answer" },
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "follow-up question" },
|
||||
]
|
||||
for await (const _chunk of handler.createMessage("system", messages)) {
|
||||
// Consume stream to trigger request execution.
|
||||
}
|
||||
|
||||
const payload = createStub.firstCall.args[0] as MoonshotRequestPayload
|
||||
payload.model.should.equal("kimi-k3")
|
||||
should(payload.max_completion_tokens).equal(moonshotModels["kimi-k3"].maxTokens)
|
||||
should(payload.max_tokens).equal(undefined)
|
||||
should(payload.temperature).equal(undefined)
|
||||
const assistantReasoning = payload.messages
|
||||
?.filter((message) => message.role === "assistant")
|
||||
.map((message) => message.reasoning_content)
|
||||
should(assistantReasoning).deepEqual([undefined, "K3 reasoning", undefined])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import "should"
|
||||
import type OpenAI from "openai"
|
||||
import sinon from "sinon"
|
||||
import type { ApiStreamChunk } from "../../transform/stream"
|
||||
import { OpenAiHandler } from "../openai"
|
||||
|
||||
describe("OpenAiHandler", () => {
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
const createAsyncIterable = (data: unknown[] = []) => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield* data
|
||||
},
|
||||
})
|
||||
|
||||
const createHandler = (chunks: unknown[]) => {
|
||||
const handler = new OpenAiHandler({
|
||||
openAiApiKey: "test-api-key",
|
||||
openAiModelId: "test-model",
|
||||
})
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: sinon.stub().resolves(createAsyncIterable(chunks)),
|
||||
},
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as unknown as { ensureClient: () => OpenAI }, "ensureClient").returns(fakeClient as unknown as OpenAI)
|
||||
return handler
|
||||
}
|
||||
|
||||
it("should emit the latest cumulative usage snapshot only once", async () => {
|
||||
const handler = createHandler([
|
||||
{
|
||||
choices: [{ delta: { content: "O" } }],
|
||||
usage: {
|
||||
prompt_tokens: 48,
|
||||
completion_tokens: 1,
|
||||
total_tokens: 49,
|
||||
prompt_tokens_details: { cached_tokens: 32 },
|
||||
},
|
||||
},
|
||||
{
|
||||
choices: [{ delta: { content: "K" } }],
|
||||
usage: {
|
||||
prompt_tokens: 48,
|
||||
completion_tokens: 2,
|
||||
total_tokens: 50,
|
||||
prompt_tokens_details: { cached_tokens: 32 },
|
||||
},
|
||||
},
|
||||
{ choices: [{ delta: {}, finish_reason: "stop" }], usage: null },
|
||||
])
|
||||
|
||||
const chunks: ApiStreamChunk[] = []
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
chunks.should.deepEqual([
|
||||
{ type: "text", text: "O" },
|
||||
{ type: "text", text: "K" },
|
||||
{
|
||||
type: "usage",
|
||||
inputTokens: 48,
|
||||
outputTokens: 2,
|
||||
cacheReadTokens: 32,
|
||||
cacheWriteTokens: 0,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should preserve providers that emit usage only on the final chunk", async () => {
|
||||
const handler = createHandler([
|
||||
{ choices: [{ delta: { content: "OK" } }] },
|
||||
{
|
||||
choices: [],
|
||||
usage: {
|
||||
prompt_tokens: 17,
|
||||
completion_tokens: 9,
|
||||
total_tokens: 26,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const chunks: ApiStreamChunk[] = []
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
chunks.should.deepEqual([
|
||||
{ type: "text", text: "OK" },
|
||||
{
|
||||
type: "usage",
|
||||
inputTokens: 17,
|
||||
outputTokens: 9,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -69,6 +69,7 @@ describe("SapAiCoreHandler", () => {
|
||||
|
||||
it("should support different Claude model variants", () => {
|
||||
const modelVariants = [
|
||||
"anthropic--claude-sonnet-5",
|
||||
"anthropic--claude-4.6-sonnet",
|
||||
"anthropic--claude-4-sonnet",
|
||||
"anthropic--claude-4-opus",
|
||||
|
||||
@@ -134,9 +134,11 @@ interface ProviderChainOptions {
|
||||
profile?: string
|
||||
}
|
||||
|
||||
// a special jp inference profile was created for sonnet 4.6, opus 4.6, sonnet 4.5 & haiku 4.5
|
||||
// a special jp inference profile was created for newer Claude CRIS models
|
||||
// https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html
|
||||
const JP_SUPPORTED_CRIS_MODELS = [
|
||||
"anthropic.claude-sonnet-5",
|
||||
"anthropic.claude-sonnet-5:1m",
|
||||
"anthropic.claude-sonnet-4-6",
|
||||
"anthropic.claude-sonnet-4-6:1m",
|
||||
"anthropic.claude-opus-4-6-v1",
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { DeepSeekModelId, deepSeekDefaultModelId, deepSeekModels, ModelInfo } from "@shared/api"
|
||||
import { calculateApiCostOpenAI } from "@utils/cost"
|
||||
import OpenAI from "openai"
|
||||
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
|
||||
import type {
|
||||
ChatCompletionReasoningEffort,
|
||||
ChatCompletionTool as OpenAITool,
|
||||
} from "openai/resources/chat/completions"
|
||||
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { fetch } from "@/shared/net"
|
||||
@@ -15,6 +18,7 @@ import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-p
|
||||
interface DeepSeekHandlerOptions extends CommonApiHandlerOptions {
|
||||
deepSeekApiKey?: string
|
||||
apiModelId?: string
|
||||
reasoningEffort?: string
|
||||
}
|
||||
|
||||
export class DeepSeekHandler implements ApiHandler {
|
||||
@@ -98,6 +102,11 @@ export class DeepSeekHandler implements ApiHandler {
|
||||
stream_options: { include_usage: true },
|
||||
// Only set temperature for non-thinking models
|
||||
...(isDeepSeekThinkingModel ? {} : { temperature: 0 }),
|
||||
// DeepSeek thinking models accept reasoning effort (low/medium map to high, xhigh maps to max).
|
||||
// "none" isn't a valid DeepSeek value, so omit it and let the API use its default.
|
||||
...(isDeepSeekThinkingModel && this.options.reasoningEffort && this.options.reasoningEffort !== "none"
|
||||
? { reasoning_effort: this.options.reasoningEffort as ChatCompletionReasoningEffort }
|
||||
: {}),
|
||||
...getOpenAIToolParams(tools),
|
||||
})
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
FunctionDeclaration as GoogleTool,
|
||||
ThinkingLevel,
|
||||
} from "@google/genai"
|
||||
import { GeminiModelId, geminiDefaultModelId, geminiModels, ModelInfo } from "@shared/api"
|
||||
import { GeminiModelId, geminiDefaultModelId, geminiModels, getVertexCustomModelInfo, ModelInfo } from "@shared/api"
|
||||
import { GEMINI_FLASH_MAX_OUTPUT_TOKENS, isGeminiFlashModel } from "@utils/model-utils"
|
||||
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
@@ -25,6 +25,7 @@ interface GeminiHandlerOptions extends CommonApiHandlerOptions {
|
||||
isVertex?: boolean
|
||||
vertexProjectId?: string
|
||||
vertexRegion?: string
|
||||
vertexCustomModelInfo?: ModelInfo
|
||||
geminiApiKey?: string
|
||||
geminiBaseUrl?: string
|
||||
thinkingBudgetTokens?: number
|
||||
@@ -464,12 +465,15 @@ export class GeminiHandler implements ApiHandler {
|
||||
/**
|
||||
* Get the model ID and info for the current configuration
|
||||
*/
|
||||
getModel(): { id: GeminiModelId; info: ModelInfo } {
|
||||
getModel(): { id: string; info: ModelInfo } {
|
||||
const modelId = this.options.apiModelId
|
||||
if (modelId && modelId in geminiModels) {
|
||||
const id = modelId as GeminiModelId
|
||||
return { id, info: geminiModels[id] }
|
||||
}
|
||||
if (modelId && this.options.isVertex) {
|
||||
return { id: modelId, info: getVertexCustomModelInfo(this.options.vertexCustomModelInfo) }
|
||||
}
|
||||
return {
|
||||
id: geminiDefaultModelId,
|
||||
info: geminiModels[geminiDefaultModelId],
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createOpenAIClient } from "@/shared/net"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from "../index"
|
||||
import { withRetry } from "../retry"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { addReasoningContent } from "../transform/r1-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
|
||||
|
||||
@@ -48,16 +49,25 @@ export class MoonshotHandler implements ApiHandler {
|
||||
const client = this.ensureClient()
|
||||
const model = this.getModel()
|
||||
|
||||
const convertedMessages = convertToOpenAiMessages(messages)
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...convertToOpenAiMessages(messages),
|
||||
...(model.id === "kimi-k3"
|
||||
? addReasoningContent(convertedMessages, messages, {
|
||||
includePreviousTurns: true,
|
||||
shouldIncludeReasoning: (message) =>
|
||||
message.modelInfo?.providerId === "moonshot" && message.modelInfo.modelId === "kimi-k3",
|
||||
})
|
||||
: convertedMessages),
|
||||
]
|
||||
|
||||
const stream = await client.chat.completions.create({
|
||||
model: model.id,
|
||||
messages: openAiMessages,
|
||||
temperature: model.info.temperature,
|
||||
max_tokens: model.info.maxTokens,
|
||||
// Kimi K3 fixes its sampling parameters and uses max_completion_tokens.
|
||||
...(model.id === "kimi-k3"
|
||||
? { max_completion_tokens: model.info.maxTokens }
|
||||
: { max_tokens: model.info.maxTokens, temperature: model.info.temperature }),
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
...getOpenAIToolParams(tools),
|
||||
|
||||
@@ -145,6 +145,7 @@ export class OpenAiHandler implements ApiHandler {
|
||||
})
|
||||
|
||||
const toolCallProcessor = new ToolCallProcessor()
|
||||
let latestUsage: OpenAI.Completions.CompletionUsage | undefined
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices?.[0]?.delta
|
||||
@@ -167,14 +168,18 @@ export class OpenAiHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
|
||||
// @ts-expect-error-next-line
|
||||
cacheWriteTokens: chunk.usage.prompt_cache_miss_tokens || 0,
|
||||
}
|
||||
latestUsage = chunk.usage
|
||||
}
|
||||
}
|
||||
|
||||
if (latestUsage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: latestUsage.prompt_tokens || 0,
|
||||
outputTokens: latestUsage.completion_tokens || 0,
|
||||
cacheReadTokens: latestUsage.prompt_tokens_details?.cached_tokens || 0,
|
||||
// @ts-expect-error-next-line
|
||||
cacheWriteTokens: latestUsage.prompt_cache_miss_tokens || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -672,6 +672,7 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
|
||||
const anthropicModels = [
|
||||
"anthropic--claude-4.5-haiku",
|
||||
"anthropic--claude-sonnet-5",
|
||||
"anthropic--claude-4.7-opus",
|
||||
"anthropic--claude-4.6-opus",
|
||||
"anthropic--claude-4.5-opus",
|
||||
@@ -725,6 +726,7 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
"amazon--nova-micro",
|
||||
];
|
||||
const converseStreamModels = [
|
||||
"anthropic--claude-sonnet-5",
|
||||
"anthropic--claude-4.7-opus",
|
||||
"anthropic--claude-4.6-opus",
|
||||
"anthropic--claude-4.5-opus",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Tool as AnthropicTool } from "@anthropic-ai/sdk/resources/index"
|
||||
import { AnthropicVertex } from "@anthropic-ai/vertex-sdk"
|
||||
import { FunctionDeclaration as GoogleTool } from "@google/genai"
|
||||
import { CLAUDE_SONNET_1M_SUFFIX, ModelInfo, VertexModelId, vertexDefaultModelId, vertexModels } from "@shared/api"
|
||||
import { CLAUDE_SONNET_1M_SUFFIX, getVertexCustomModelInfo, ModelInfo, vertexDefaultModelId, vertexModels } from "@shared/api"
|
||||
import { isClaudeOpusAdaptiveThinkingModel, resolveClaudeOpusAdaptiveThinking } from "@shared/utils/reasoning-support"
|
||||
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
@@ -16,6 +16,7 @@ interface VertexHandlerOptions extends CommonApiHandlerOptions {
|
||||
vertexProjectId?: string
|
||||
vertexRegion?: string
|
||||
apiModelId?: string
|
||||
vertexCustomModelInfo?: ModelInfo
|
||||
thinkingBudgetTokens?: number
|
||||
geminiApiKey?: string
|
||||
geminiBaseUrl?: string
|
||||
@@ -267,12 +268,15 @@ export class VertexHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
getModel(): { id: VertexModelId; info: ModelInfo } {
|
||||
getModel(): { id: string; info: ModelInfo } {
|
||||
const modelId = this.options.apiModelId
|
||||
if (modelId && modelId in vertexModels) {
|
||||
const id = modelId as VertexModelId
|
||||
const id = modelId as keyof typeof vertexModels
|
||||
return { id, info: vertexModels[id] }
|
||||
}
|
||||
if (modelId) {
|
||||
return { id: modelId, info: getVertexCustomModelInfo(this.options.vertexCustomModelInfo) }
|
||||
}
|
||||
return {
|
||||
id: vertexDefaultModelId,
|
||||
info: vertexModels[vertexDefaultModelId],
|
||||
|
||||
@@ -78,6 +78,19 @@ describe("createOpenRouterStream", () => {
|
||||
payload.should.not.have.property("max_tokens")
|
||||
})
|
||||
|
||||
it("strips the custom Sonnet 5 1m suffix and pins Anthropic/Vertex routing", async () => {
|
||||
const { client, create } = createClient()
|
||||
|
||||
await createOpenRouterStream(client as any, "system prompt", [{ role: "user", content: "hello" }] as any, {
|
||||
id: "anthropic/claude-sonnet-5:1m",
|
||||
info: createModelInfo(128_000),
|
||||
})
|
||||
|
||||
const payload = create.firstCall.args[0] as any
|
||||
payload.should.have.property("model", "anthropic/claude-sonnet-5")
|
||||
payload.provider.should.deepEqual({ order: ["anthropic", "google-vertex/global"], allow_fallbacks: false })
|
||||
})
|
||||
|
||||
it("adds cache_control blocks for Qwen models that require explicit OpenRouter caching", async () => {
|
||||
for (const modelId of ["qwen/qwen3.6-plus", "qwen/qwen3.7-max"]) {
|
||||
const { client, create } = createClient()
|
||||
@@ -114,4 +127,87 @@ describe("createOpenRouterStream", () => {
|
||||
should(payload.temperature).equal(undefined)
|
||||
should(payload.top_p).equal(undefined)
|
||||
})
|
||||
|
||||
it("includes reasoning for Claude budget-based reasoning models", async () => {
|
||||
const { client, create } = createClient()
|
||||
|
||||
await createOpenRouterStream(
|
||||
client as any,
|
||||
"system prompt",
|
||||
[{ role: "user", content: "hello" }] as any,
|
||||
{
|
||||
id: "anthropic/claude-sonnet-4.5",
|
||||
info: createModelInfo(64_000),
|
||||
},
|
||||
undefined,
|
||||
16_384,
|
||||
)
|
||||
|
||||
const payload = create.firstCall.args[0] as any
|
||||
payload.should.have.property("include_reasoning", true)
|
||||
payload.reasoning.should.deepEqual({ max_tokens: 16_384 })
|
||||
should(payload.temperature).equal(undefined)
|
||||
})
|
||||
|
||||
it("sends reasoning effort instead of token budgets for supported OpenRouter/Cline model families", async () => {
|
||||
for (const modelId of [
|
||||
"zai/glm-5.2",
|
||||
"z-ai/glm-5.2",
|
||||
"moonshotai/kimi-k2-thinking",
|
||||
"accounts/fireworks/models/minimax-m3",
|
||||
"provider/mimo-vl",
|
||||
"qwen/qwen3.7-max",
|
||||
"deepseek/deepseek-r1",
|
||||
]) {
|
||||
const { client, create } = createClient()
|
||||
|
||||
await createOpenRouterStream(
|
||||
client as any,
|
||||
"system prompt",
|
||||
[{ role: "user", content: "hello" }] as any,
|
||||
{
|
||||
id: modelId,
|
||||
info: { ...createModelInfo(131_072), thinkingConfig: { maxBudget: 16_384 } },
|
||||
},
|
||||
"high",
|
||||
16_384,
|
||||
)
|
||||
|
||||
const payload = create.firstCall.args[0] as any
|
||||
payload.should.have.property("include_reasoning", true)
|
||||
payload.reasoning.should.deepEqual({ effort: "high" })
|
||||
}
|
||||
})
|
||||
|
||||
it("does not send reasoning effort for ClinePass requests when unset", async () => {
|
||||
const { client, create } = createClient()
|
||||
|
||||
await createOpenRouterStream(client as any, "system prompt", [{ role: "user", content: "hello" }] as any, {
|
||||
id: "cline-pass/glm-5.2",
|
||||
info: createModelInfo(131_072),
|
||||
})
|
||||
|
||||
const payload = create.firstCall.args[0] as any
|
||||
payload.should.have.property("include_reasoning", true)
|
||||
payload.should.not.have.property("reasoning")
|
||||
})
|
||||
|
||||
it("sends the selected reasoning effort for ClinePass requests", async () => {
|
||||
const { client, create } = createClient()
|
||||
|
||||
await createOpenRouterStream(
|
||||
client as any,
|
||||
"system prompt",
|
||||
[{ role: "user", content: "hello" }] as any,
|
||||
{
|
||||
id: "cline-pass/glm-5.2",
|
||||
info: createModelInfo(131_072),
|
||||
},
|
||||
"high",
|
||||
)
|
||||
|
||||
const payload = create.firstCall.args[0] as any
|
||||
payload.should.have.property("include_reasoning", true)
|
||||
payload.reasoning.should.deepEqual({ effort: "high" })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,10 +4,12 @@ import {
|
||||
ModelInfo,
|
||||
OPENROUTER_PROVIDER_PREFERENCES,
|
||||
openRouterClaudeFable51mModelId,
|
||||
openRouterClaudeOpus51mModelId,
|
||||
openRouterClaudeOpus461mModelId,
|
||||
openRouterClaudeOpus471mModelId,
|
||||
openRouterClaudeOpus481mModelId,
|
||||
openRouterClaudeSonnet41mModelId,
|
||||
openRouterClaudeSonnet51mModelId,
|
||||
openRouterClaudeSonnet451mModelId,
|
||||
openRouterClaudeSonnet461mModelId,
|
||||
} from "@shared/api"
|
||||
@@ -62,9 +64,11 @@ export async function createOpenRouterStream(
|
||||
model.id === openRouterClaudeSonnet41mModelId ||
|
||||
model.id === openRouterClaudeSonnet451mModelId ||
|
||||
model.id === openRouterClaudeSonnet461mModelId ||
|
||||
model.id === openRouterClaudeSonnet51mModelId ||
|
||||
model.id === openRouterClaudeOpus461mModelId ||
|
||||
model.id === openRouterClaudeOpus471mModelId ||
|
||||
model.id === openRouterClaudeOpus481mModelId ||
|
||||
model.id === openRouterClaudeOpus51mModelId ||
|
||||
model.id === openRouterClaudeFable51mModelId
|
||||
if (isClaude1m) {
|
||||
// remove the custom :1m suffix, to create the model id openrouter API expects
|
||||
@@ -147,6 +151,8 @@ export async function createOpenRouterStream(
|
||||
switch (model.id) {
|
||||
case "anthropic/claude-haiku-4.5":
|
||||
case "anthropic/claude-4.5-haiku":
|
||||
case "anthropic/claude-sonnet-5":
|
||||
case "anthropic/claude-5-sonnet":
|
||||
case "anthropic/claude-sonnet-4.6":
|
||||
case "anthropic/claude-4.6-sonnet":
|
||||
case "anthropic/claude-sonnet-4.5":
|
||||
|
||||
@@ -14,14 +14,20 @@ export type DeepSeekReasonerMessage = OpenAI.Chat.ChatCompletionMessageParam & {
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds reasoning_content to OpenAI messages for DeepSeek Reasoner.
|
||||
* Per DeepSeek API: reasoning_content should be passed back during tool calling in the same turn,
|
||||
* and omitted when starting a new turn.
|
||||
* Adds reasoning_content to OpenAI-compatible messages.
|
||||
* DeepSeek only needs it during tool calling in the current turn. Providers such as Moonshot K3
|
||||
* can include previous turns and filter reasoning by its originating message.
|
||||
*/
|
||||
export function addReasoningContent(
|
||||
openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[],
|
||||
originalMessages: ClineStorageMessage[],
|
||||
options: {
|
||||
includePreviousTurns?: boolean;
|
||||
shouldIncludeReasoning?: (message: ClineStorageMessage) => boolean;
|
||||
} = {},
|
||||
): DeepSeekReasonerMessage[] {
|
||||
const { includePreviousTurns = false, shouldIncludeReasoning = () => true } = options;
|
||||
|
||||
// Find last user message index (start of current turn)
|
||||
// If no user message exists (lastUserIndex = -1), all messages are in the "current turn",
|
||||
// so reasoning_content will be added to all assistant messages. This is intentional.
|
||||
@@ -38,7 +44,7 @@ export function addReasoningContent(
|
||||
let assistantIdx = 0;
|
||||
for (const msg of originalMessages) {
|
||||
if (msg.role === "assistant") {
|
||||
if (Array.isArray(msg.content)) {
|
||||
if (Array.isArray(msg.content) && shouldIncludeReasoning(msg)) {
|
||||
const thinking = msg.content
|
||||
.filter(
|
||||
(p): p is ClineAssistantThinkingBlock => p.type === "thinking",
|
||||
@@ -53,12 +59,12 @@ export function addReasoningContent(
|
||||
}
|
||||
}
|
||||
|
||||
// Add reasoning_content only to assistant messages in current turn
|
||||
// Add reasoning_content to assistant messages in the requested scope.
|
||||
let aiIdx = 0;
|
||||
return openAiMessages.map((msg, i): DeepSeekReasonerMessage => {
|
||||
if (msg.role === "assistant") {
|
||||
const thinking = thinkingByIndex.get(aiIdx++);
|
||||
if (thinking && i >= lastUserIndex) {
|
||||
if (thinking && (includePreviousTurns || i >= lastUserIndex)) {
|
||||
return { ...msg, reasoning_content: thinking };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ import {
|
||||
CLAUDE_SONNET_1M_SUFFIX,
|
||||
ModelInfo,
|
||||
openRouterClaudeFable51mModelId,
|
||||
openRouterClaudeOpus51mModelId,
|
||||
openRouterClaudeOpus461mModelId,
|
||||
openRouterClaudeOpus471mModelId,
|
||||
openRouterClaudeOpus481mModelId,
|
||||
openRouterClaudeSonnet41mModelId,
|
||||
openRouterClaudeSonnet51mModelId,
|
||||
openRouterClaudeSonnet451mModelId,
|
||||
openRouterClaudeSonnet461mModelId,
|
||||
} from "@shared/api"
|
||||
@@ -38,9 +40,11 @@ export async function createVercelAIGatewayStream(
|
||||
model.id === openRouterClaudeSonnet41mModelId ||
|
||||
model.id === openRouterClaudeSonnet451mModelId ||
|
||||
model.id === openRouterClaudeSonnet461mModelId ||
|
||||
model.id === openRouterClaudeSonnet51mModelId ||
|
||||
model.id === openRouterClaudeOpus461mModelId ||
|
||||
model.id === openRouterClaudeOpus471mModelId ||
|
||||
model.id === openRouterClaudeOpus481mModelId ||
|
||||
model.id === openRouterClaudeOpus51mModelId ||
|
||||
model.id === openRouterClaudeFable51mModelId
|
||||
if (isClaude1m) {
|
||||
// remove the custom :1m suffix, to create the model id the API expects
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import * as yaml from "js-yaml"
|
||||
import * as yaml from "js-yaml";
|
||||
|
||||
export type FrontmatterParseResult = {
|
||||
data: Record<string, unknown>
|
||||
data: Record<string, unknown>;
|
||||
/**
|
||||
* The markdown content after stripping the `--- frontmatter ---` block.
|
||||
*
|
||||
* Named `body` (rather than `content`) to make it clear this is the remaining
|
||||
* document body and to keep this helper generic for multiple consumers.
|
||||
*/
|
||||
body: string
|
||||
body: string;
|
||||
|
||||
/**
|
||||
* True when the input contained a frontmatter block, even if parsing failed.
|
||||
@@ -17,15 +17,15 @@ export type FrontmatterParseResult = {
|
||||
* - "no frontmatter provided" (baseline behavior), vs
|
||||
* - "frontmatter was provided" (may have semantic meaning in future consumers).
|
||||
*/
|
||||
hadFrontmatter: boolean
|
||||
hadFrontmatter: boolean;
|
||||
/**
|
||||
* Present only when YAML frontmatter was detected but failed to parse.
|
||||
*
|
||||
* This helper is intentionally fail-open and does not log. Returning `parseError`
|
||||
* lets each caller decide whether to log, surface diagnostics, etc.
|
||||
*/
|
||||
parseError?: string
|
||||
}
|
||||
parseError?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse YAML frontmatter from markdown content.
|
||||
@@ -35,19 +35,32 @@ export type FrontmatterParseResult = {
|
||||
* - If no frontmatter exists, returns data={} and body=original markdown.
|
||||
*/
|
||||
export function parseYamlFrontmatter(markdown: string): FrontmatterParseResult {
|
||||
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/
|
||||
const match = markdown.match(frontmatterRegex)
|
||||
if (markdown.charCodeAt(0) === 0xfeff) {
|
||||
markdown = markdown.slice(1); // Remove BOM format if present
|
||||
}
|
||||
|
||||
const frontmatterRegex = /^\s*---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
||||
const match = markdown.match(frontmatterRegex);
|
||||
|
||||
if (!match) {
|
||||
return { data: {}, body: markdown, hadFrontmatter: false }
|
||||
return { data: {}, body: markdown, hadFrontmatter: false };
|
||||
}
|
||||
|
||||
const [, yamlContent, body] = match
|
||||
const [, yamlContent, body] = match;
|
||||
try {
|
||||
const data = (yaml.load(yamlContent, { schema: yaml.JSON_SCHEMA }) as Record<string, unknown>) || {}
|
||||
return { data, body, hadFrontmatter: true }
|
||||
const data =
|
||||
(yaml.load(yamlContent, { schema: yaml.JSON_SCHEMA }) as Record<
|
||||
string,
|
||||
unknown
|
||||
>) || {};
|
||||
return { data, body, hadFrontmatter: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return { data: {}, body: markdown, hadFrontmatter: true, parseError: message }
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
data: {},
|
||||
body: markdown,
|
||||
hadFrontmatter: true,
|
||||
parseError: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { McpHub } from "@services/mcp/McpHub"
|
||||
import type { ApiProvider, ModelInfo } from "@shared/api"
|
||||
import type { ChatContent } from "@shared/ChatContent"
|
||||
import type { ExtensionState, Platform } from "@shared/ExtensionMessage"
|
||||
import { DEFAULT_FOCUS_CHAIN_SETTINGS } from "@shared/FocusChainSettings"
|
||||
import type { HistoryItem } from "@shared/HistoryItem"
|
||||
import type { McpMarketplaceCatalog, McpMarketplaceItem } from "@shared/mcp"
|
||||
import { type Settings } from "@shared/storage/state-keys"
|
||||
@@ -523,8 +524,7 @@ export class Controller {
|
||||
|
||||
// On login we route the user to the managed "cline" provider, but preserve a
|
||||
// "cline-pass" selection made during onboarding (otherwise it would be clobbered).
|
||||
// A "cline-pass" provider can only be set when the ext-cline-pass flag is on, so
|
||||
// non-ClinePass logins are unaffected.
|
||||
// Non-ClinePass logins are unaffected because they do not persist "cline-pass".
|
||||
const planProvider: ApiProvider =
|
||||
currentApiConfiguration.planModeApiProvider === "cline-pass" ? "cline-pass" : "cline"
|
||||
const actProvider: ApiProvider = currentApiConfiguration.actModeApiProvider === "cline-pass" ? "cline-pass" : "cline"
|
||||
@@ -856,7 +856,7 @@ export class Controller {
|
||||
const taskHistory = this.stateManager.getGlobalStateKey("taskHistory")
|
||||
const autoApprovalSettings = this.stateManager.getGlobalSettingsKey("autoApprovalSettings")
|
||||
const browserSettings = this.stateManager.getGlobalSettingsKey("browserSettings")
|
||||
const focusChainSettings = this.stateManager.getGlobalSettingsKey("focusChainSettings")
|
||||
const focusChainSettings = this.stateManager.getGlobalSettingsKey("focusChainSettings") ?? DEFAULT_FOCUS_CHAIN_SETTINGS
|
||||
const preferredLanguage = this.stateManager.getGlobalSettingsKey("preferredLanguage")
|
||||
const mode = this.stateManager.getGlobalSettingsKey("mode")
|
||||
const strictPlanModeEnabled = this.stateManager.getGlobalSettingsKey("strictPlanModeEnabled")
|
||||
|
||||
+2
-2
@@ -25,7 +25,7 @@ describe("clearOrganizationForClinePassProviderSelection", () => {
|
||||
} as unknown as Controller;
|
||||
}
|
||||
|
||||
it("does nothing when Cline Pass is not selected", async () => {
|
||||
it("does nothing when ClinePass is not selected", async () => {
|
||||
await clearOrganizationForClinePassProviderSelection(createController(), {
|
||||
planModeApiProvider: "cline",
|
||||
actModeApiProvider: "openrouter",
|
||||
@@ -34,7 +34,7 @@ describe("clearOrganizationForClinePassProviderSelection", () => {
|
||||
assert.strictEqual(switchAccount.callCount, 0);
|
||||
});
|
||||
|
||||
it("switches to the personal account when Cline Pass is selected", async () => {
|
||||
it("switches to the personal account when ClinePass is selected", async () => {
|
||||
await clearOrganizationForClinePassProviderSelection(createController(), {
|
||||
planModeApiProvider: "cline-pass",
|
||||
actModeApiProvider: "openrouter",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as disk from "@core/storage/disk"
|
||||
import { openRouterClaudeFable51mModelId } from "@shared/api"
|
||||
import { openRouterClaudeFable51mModelId, openRouterClaudeOpus51mModelId } from "@shared/api"
|
||||
import axios from "axios"
|
||||
import { expect } from "chai"
|
||||
import fs from "fs/promises"
|
||||
@@ -135,4 +135,131 @@ describe("refreshClineModels", () => {
|
||||
expect(fable1m.contextWindow).to.equal(1_000_000)
|
||||
expect(fable1m.tiers).to.not.equal(undefined)
|
||||
})
|
||||
|
||||
it("adds Claude Opus 5 context variants to the Cline model list", async () => {
|
||||
sandbox.stub(getFeatureFlagsService(), "getBooleanFlagEnabled").callsFake((flag) => {
|
||||
return flag === FeatureFlag.EXTENSION_CLINE_MODELS_ENDPOINT
|
||||
})
|
||||
sandbox.stub(ClineEnv, "config").returns({
|
||||
environment: Environment.production,
|
||||
appBaseUrl: "https://app.cline-mock.bot",
|
||||
apiBaseUrl: "https://api.cline-mock.bot",
|
||||
mcpBaseUrl: "https://api.cline-mock.bot/v1/mcp",
|
||||
})
|
||||
sandbox.stub(StateManager, "get").returns({
|
||||
getModelsCache: () => null,
|
||||
setModelsCache: () => {},
|
||||
} as unknown as StateManager)
|
||||
sandbox.stub(disk, "ensureCacheDirectoryExists").resolves("/tmp")
|
||||
sandbox.stub(fs, "writeFile").resolves()
|
||||
sandbox.stub(axios, "get").resolves({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: "anthropic/claude-opus-5",
|
||||
name: "Claude Opus 5",
|
||||
description: "Fetched description",
|
||||
context_length: 1_000_000,
|
||||
top_provider: {
|
||||
max_completion_tokens: 128_000,
|
||||
context_length: 1_000_000,
|
||||
is_moderated: false,
|
||||
},
|
||||
architecture: {
|
||||
modality: ["text", "image"],
|
||||
},
|
||||
pricing: {
|
||||
prompt: "0.000005",
|
||||
completion: "0.000025",
|
||||
input_cache_read: "0.0000005",
|
||||
input_cache_write: "0.00000625",
|
||||
},
|
||||
supported_parameters: ["include_reasoning", "reasoning"],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const models = await refreshClineModels({} as Controller)
|
||||
const opus5 = models["anthropic/claude-opus-5"]
|
||||
const opus51m = models[openRouterClaudeOpus51mModelId]
|
||||
|
||||
expect(opus5.contextWindow).to.equal(200_000)
|
||||
expect(opus5.maxTokens).to.equal(128_000)
|
||||
expect(opus5.supportsPromptCache).to.equal(true)
|
||||
expect(opus5.inputPrice).to.equal(5)
|
||||
expect(opus5.outputPrice).to.equal(25)
|
||||
expect(opus5.cacheWritesPrice).to.equal(6.25)
|
||||
expect(opus5.cacheReadsPrice).to.equal(0.5)
|
||||
expect(opus51m.contextWindow).to.equal(1_000_000)
|
||||
expect(opus51m.tiers).to.not.equal(undefined)
|
||||
})
|
||||
|
||||
it("prefers Vercel-style Z.ai IDs when the Cline model list includes OpenRouter aliases", async () => {
|
||||
sandbox.stub(getFeatureFlagsService(), "getBooleanFlagEnabled").callsFake((flag) => {
|
||||
return flag === FeatureFlag.EXTENSION_CLINE_MODELS_ENDPOINT
|
||||
})
|
||||
sandbox.stub(ClineEnv, "config").returns({
|
||||
environment: Environment.production,
|
||||
appBaseUrl: "https://app.cline-mock.bot",
|
||||
apiBaseUrl: "https://api.cline-mock.bot",
|
||||
mcpBaseUrl: "https://api.cline-mock.bot/v1/mcp",
|
||||
})
|
||||
sandbox.stub(StateManager, "get").returns({
|
||||
getModelsCache: () => null,
|
||||
setModelsCache: () => {},
|
||||
} as unknown as StateManager)
|
||||
sandbox.stub(disk, "ensureCacheDirectoryExists").resolves("/tmp")
|
||||
sandbox.stub(fs, "writeFile").resolves()
|
||||
sandbox.stub(axios, "get").resolves({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: "z-ai/glm-5.2",
|
||||
name: "OpenRouter GLM 5.2",
|
||||
description: "OpenRouter alias",
|
||||
context_length: 128_000,
|
||||
top_provider: {
|
||||
max_completion_tokens: 8_192,
|
||||
context_length: 128_000,
|
||||
is_moderated: false,
|
||||
},
|
||||
architecture: {
|
||||
modality: "text->text",
|
||||
},
|
||||
pricing: {
|
||||
prompt: "0.00000098",
|
||||
completion: "0.00000308",
|
||||
},
|
||||
supported_parameters: ["include_reasoning", "reasoning"],
|
||||
},
|
||||
{
|
||||
id: "zai/glm-5.2",
|
||||
name: "GLM 5.2",
|
||||
description: "Vercel canonical ID",
|
||||
context_length: 1_000_000,
|
||||
top_provider: {
|
||||
max_completion_tokens: 131_072,
|
||||
context_length: 1_000_000,
|
||||
is_moderated: false,
|
||||
},
|
||||
architecture: {
|
||||
modality: "text->text",
|
||||
},
|
||||
pricing: {
|
||||
prompt: "0.0000015",
|
||||
completion: "0.0000045",
|
||||
},
|
||||
supported_parameters: ["include_reasoning", "reasoning"],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const models = await refreshClineModels({} as Controller)
|
||||
|
||||
expect(models["zai/glm-5.2"]).to.not.equal(undefined)
|
||||
expect(models["zai/glm-5.2"].contextWindow).to.equal(1_000_000)
|
||||
expect(models["z-ai/glm-5.2"]).to.equal(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
+101
-6
@@ -39,7 +39,7 @@ describe("refreshClineRecommendedModels", () => {
|
||||
data: {
|
||||
recommended: [
|
||||
{
|
||||
id: "anthropic/claude-sonnet-4.6",
|
||||
id: "anthropic/claude-sonnet-5",
|
||||
description: "Remote recommended",
|
||||
tags: ["NEW"],
|
||||
},
|
||||
@@ -48,7 +48,7 @@ describe("refreshClineRecommendedModels", () => {
|
||||
clinePass: [
|
||||
{
|
||||
id: "cline-pass/glm-5",
|
||||
description: "Remote Cline Pass",
|
||||
description: "Remote ClinePass",
|
||||
tags: ["CLINE_PASS"],
|
||||
},
|
||||
],
|
||||
@@ -61,8 +61,8 @@ describe("refreshClineRecommendedModels", () => {
|
||||
expect(result).to.deep.equal({
|
||||
recommended: [
|
||||
{
|
||||
id: "anthropic/claude-sonnet-4.6",
|
||||
name: "anthropic/claude-sonnet-4.6",
|
||||
id: "anthropic/claude-sonnet-5",
|
||||
name: "anthropic/claude-sonnet-5",
|
||||
description: "Remote recommended",
|
||||
tags: ["NEW"],
|
||||
},
|
||||
@@ -79,7 +79,7 @@ describe("refreshClineRecommendedModels", () => {
|
||||
{
|
||||
id: "cline-pass/glm-5",
|
||||
name: "cline-pass/glm-5",
|
||||
description: "Remote Cline Pass",
|
||||
description: "Remote ClinePass",
|
||||
tags: ["CLINE_PASS"],
|
||||
},
|
||||
],
|
||||
@@ -114,7 +114,7 @@ describe("refreshClineRecommendedModels", () => {
|
||||
clinePass: [
|
||||
{
|
||||
id: "cline-pass/glm-5",
|
||||
description: "Remote Cline Pass",
|
||||
description: "Remote ClinePass",
|
||||
tags: ["CLINE_PASS"],
|
||||
},
|
||||
],
|
||||
@@ -127,4 +127,99 @@ describe("refreshClineRecommendedModels", () => {
|
||||
expect(axiosGetStub.calledOnce).to.equal(true);
|
||||
expect(secondResult).to.deep.equal(firstResult);
|
||||
});
|
||||
|
||||
it("normalizes Cline provider Z.ai recommended IDs to the Cline API alias", async () => {
|
||||
sandbox.stub(ClineEnv, "config").returns({
|
||||
environment: Environment.production,
|
||||
appBaseUrl: "https://app.cline-mock.bot",
|
||||
apiBaseUrl: "https://api.cline-mock.bot",
|
||||
mcpBaseUrl: "https://api.cline-mock.bot/v1/mcp",
|
||||
});
|
||||
sandbox.stub(disk, "ensureCacheDirectoryExists").resolves("/tmp");
|
||||
sandbox.stub(fs, "writeFile").resolves();
|
||||
sandbox.stub(axios, "get").resolves({
|
||||
data: {
|
||||
recommended: [
|
||||
{
|
||||
id: "zai/glm-5.2",
|
||||
name: "zai/glm-5.2",
|
||||
description: "Recommended GLM",
|
||||
},
|
||||
],
|
||||
free: [
|
||||
{
|
||||
id: "zai/free-glm",
|
||||
description: "Free GLM",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await refreshClineRecommendedModels();
|
||||
|
||||
expect(result.recommended[0]).to.include({
|
||||
id: "z-ai/glm-5.2",
|
||||
name: "z-ai/glm-5.2",
|
||||
});
|
||||
expect(result.free[0]).to.include({
|
||||
id: "z-ai/free-glm",
|
||||
name: "z-ai/free-glm",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes cached Cline provider Z.ai recommended IDs", async () => {
|
||||
sandbox.stub(ClineEnv, "config").returns({
|
||||
environment: Environment.production,
|
||||
appBaseUrl: "https://app.cline-mock.bot",
|
||||
apiBaseUrl: "https://api.cline-mock.bot",
|
||||
mcpBaseUrl: "https://api.cline-mock.bot/v1/mcp",
|
||||
});
|
||||
sandbox.stub(disk, "ensureCacheDirectoryExists").resolves("/tmp");
|
||||
sandbox.stub(axios, "get").rejects(new Error("network unavailable"));
|
||||
sandbox.stub(fs, "access").resolves();
|
||||
sandbox.stub(fs, "readFile").resolves(
|
||||
JSON.stringify({
|
||||
recommended: [
|
||||
{
|
||||
id: "zai/glm-5.2",
|
||||
name: "zai/glm-5.2",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await refreshClineRecommendedModels();
|
||||
|
||||
expect(result.recommended.map((model) => model.id)).to.deep.equal(["z-ai/glm-5.2"]);
|
||||
expect(result.recommended.map((model) => model.name)).to.deep.equal(["z-ai/glm-5.2"]);
|
||||
});
|
||||
|
||||
it("prefers canonical ClinePass Z.ai IDs when aliases are also present", async () => {
|
||||
sandbox.stub(ClineEnv, "config").returns({
|
||||
environment: Environment.production,
|
||||
appBaseUrl: "https://app.cline-mock.bot",
|
||||
apiBaseUrl: "https://api.cline-mock.bot",
|
||||
mcpBaseUrl: "https://api.cline-mock.bot/v1/mcp",
|
||||
});
|
||||
sandbox.stub(disk, "ensureCacheDirectoryExists").resolves("/tmp");
|
||||
sandbox.stub(fs, "writeFile").resolves();
|
||||
sandbox.stub(axios, "get").resolves({
|
||||
data: {
|
||||
clinePass: [
|
||||
{
|
||||
id: "cline-pass/z-ai/glm-5.2",
|
||||
description: "OpenRouter alias",
|
||||
},
|
||||
{
|
||||
id: "cline-pass/zai/glm-5.2",
|
||||
description: "Canonical ID",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await refreshClineRecommendedModels();
|
||||
|
||||
expect(result.clinePass.map((model) => model.id)).to.deep.equal(["cline-pass/zai/glm-5.2"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { Controller } from "../index"
|
||||
export const CLINE_PASS_PROVIDER_ID = "cline-pass"
|
||||
|
||||
/**
|
||||
* Cline Pass always uses the user's personal Cline account balance.
|
||||
* ClinePass always uses the user's personal Cline account balance.
|
||||
*
|
||||
* This is intentionally best-effort: selecting the provider should still be
|
||||
* saved even if the account switch fails.
|
||||
@@ -24,6 +24,6 @@ export async function clearOrganizationForClinePassProviderSelection(
|
||||
try {
|
||||
await controller.accountService.switchAccount(null)
|
||||
} catch (error) {
|
||||
Logger.debug("Failed to switch Cline Pass to personal account", { error })
|
||||
Logger.debug("Failed to switch ClinePass to personal account", { error })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,10 +15,12 @@ import {
|
||||
CLAUDE_OPUS_1M_TIERS,
|
||||
CLAUDE_SONNET_1M_TIERS,
|
||||
openRouterClaudeFable51mModelId,
|
||||
openRouterClaudeOpus51mModelId,
|
||||
openRouterClaudeOpus461mModelId,
|
||||
openRouterClaudeOpus471mModelId,
|
||||
openRouterClaudeOpus481mModelId,
|
||||
openRouterClaudeSonnet41mModelId,
|
||||
openRouterClaudeSonnet51mModelId,
|
||||
openRouterClaudeSonnet451mModelId,
|
||||
openRouterClaudeSonnet461mModelId,
|
||||
} from "@/shared/api"
|
||||
@@ -87,6 +89,37 @@ interface ClineRawModelInfo {
|
||||
// Track pending refresh promise to prevent duplicate concurrent fetches
|
||||
let pendingRefresh: Promise<Record<string, ModelInfo>> | null = null
|
||||
|
||||
interface ModelIdAliasRule {
|
||||
canonicalPrefix: string
|
||||
aliasPrefix: string
|
||||
}
|
||||
|
||||
// Mirrors @cline/llms VERCEL_OPENROUTER_MODEL_ID_ALIAS_RULES.
|
||||
const VERCEL_OPENROUTER_MODEL_ID_ALIAS_RULES = [
|
||||
{ canonicalPrefix: "zai/", aliasPrefix: "z-ai/" },
|
||||
] as const satisfies readonly ModelIdAliasRule[]
|
||||
|
||||
function preferCanonicalModelIds<T>(models: Record<string, T>, rules: readonly ModelIdAliasRule[]): Record<string, T> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(models).filter(([modelId]) => {
|
||||
for (const rule of rules) {
|
||||
if (!modelId.startsWith(rule.aliasPrefix)) {
|
||||
continue
|
||||
}
|
||||
const canonicalModelId = `${rule.canonicalPrefix}${modelId.slice(rule.aliasPrefix.length)}`
|
||||
if (canonicalModelId in models) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function preferClineCanonicalModelIds(models: Record<string, ModelInfo>): Record<string, ModelInfo> {
|
||||
return preferCanonicalModelIds(models, VERCEL_OPENROUTER_MODEL_ID_ALIAS_RULES)
|
||||
}
|
||||
|
||||
async function fetchRawClineModels(): Promise<ClineRawModelInfo[]> {
|
||||
const apiBaseUrl = ClineEnv.config().apiBaseUrl
|
||||
const response = await axios.get(`${apiBaseUrl}/api/v1/ai/cline/models`, getAxiosSettings())
|
||||
@@ -107,7 +140,7 @@ async function fetchRawClineModels(): Promise<ClineRawModelInfo[]> {
|
||||
export async function refreshClineModels(controller: Controller): Promise<Record<string, ModelInfo>> {
|
||||
const shouldUseClineEndpointSource = featureFlagsService.getBooleanFlagEnabled(FeatureFlag.EXTENSION_CLINE_MODELS_ENDPOINT)
|
||||
if (!shouldUseClineEndpointSource) {
|
||||
return refreshOpenRouterModels(controller)
|
||||
return preferClineCanonicalModelIds(await refreshOpenRouterModels(controller))
|
||||
}
|
||||
|
||||
// Check in-memory cache first
|
||||
@@ -181,6 +214,15 @@ async function fetchAndCacheClineModels(): Promise<Record<string, ModelInfo>> {
|
||||
|
||||
// Apply model-specific overrides for known models
|
||||
switch (rawModel.id) {
|
||||
case "anthropic/claude-sonnet-5":
|
||||
case "anthropic/claude-5-sonnet":
|
||||
modelInfo.contextWindow = 200_000
|
||||
modelInfo.supportsPromptCache = true
|
||||
modelInfo.inputPrice = 2.0
|
||||
modelInfo.outputPrice = 10.0
|
||||
modelInfo.cacheWritesPrice = 2.5
|
||||
modelInfo.cacheReadsPrice = 0.2
|
||||
break
|
||||
case "anthropic/claude-sonnet-4.6":
|
||||
case "anthropic/claude-4.6-sonnet":
|
||||
case "anthropic/claude-sonnet-4.5":
|
||||
@@ -198,6 +240,15 @@ async function fetchAndCacheClineModels(): Promise<Record<string, ModelInfo>> {
|
||||
modelInfo.cacheWritesPrice = 3.75
|
||||
modelInfo.cacheReadsPrice = 0.3
|
||||
break
|
||||
case "anthropic/claude-opus-5":
|
||||
case "anthropic/claude-5-opus":
|
||||
modelInfo.contextWindow = 200_000
|
||||
modelInfo.supportsPromptCache = true
|
||||
modelInfo.inputPrice = 5.0
|
||||
modelInfo.outputPrice = 25.0
|
||||
modelInfo.cacheWritesPrice = 6.25
|
||||
modelInfo.cacheReadsPrice = 0.5
|
||||
break
|
||||
case "anthropic/claude-opus-4.6":
|
||||
case "anthropic/claude-opus-4.7":
|
||||
case "anthropic/claude-opus-4.8":
|
||||
@@ -273,12 +324,17 @@ async function fetchAndCacheClineModels(): Promise<Record<string, ModelInfo>> {
|
||||
rawModel.id === "anthropic/claude-sonnet-4" ||
|
||||
rawModel.id === "anthropic/claude-sonnet-4.5" ||
|
||||
rawModel.id === "anthropic/claude-sonnet-4.6" ||
|
||||
rawModel.id === "anthropic/claude-4.6-sonnet"
|
||||
rawModel.id === "anthropic/claude-4.6-sonnet" ||
|
||||
rawModel.id === "anthropic/claude-sonnet-5" ||
|
||||
rawModel.id === "anthropic/claude-5-sonnet"
|
||||
) {
|
||||
const claudeSonnet1mModelInfo = cloneDeep(modelInfo)
|
||||
claudeSonnet1mModelInfo.contextWindow = 1_000_000
|
||||
claudeSonnet1mModelInfo.tiers = CLAUDE_SONNET_1M_TIERS
|
||||
|
||||
if (rawModel.id === "anthropic/claude-sonnet-5" || rawModel.id === "anthropic/claude-5-sonnet") {
|
||||
models[openRouterClaudeSonnet51mModelId] = claudeSonnet1mModelInfo
|
||||
}
|
||||
if (rawModel.id === "anthropic/claude-sonnet-4") {
|
||||
models[openRouterClaudeSonnet41mModelId] = claudeSonnet1mModelInfo
|
||||
}
|
||||
@@ -309,6 +365,12 @@ async function fetchAndCacheClineModels(): Promise<Record<string, ModelInfo>> {
|
||||
models[openRouterClaudeOpus481mModelId] = claudeOpus1mModelInfo
|
||||
}
|
||||
}
|
||||
if (rawModel.id === "anthropic/claude-opus-5" || rawModel.id === "anthropic/claude-5-opus") {
|
||||
const claudeOpus51mModelInfo = cloneDeep(modelInfo)
|
||||
claudeOpus51mModelInfo.contextWindow = 1_000_000
|
||||
claudeOpus51mModelInfo.tiers = CLAUDE_OPUS_1M_TIERS
|
||||
models[openRouterClaudeOpus51mModelId] = claudeOpus51mModelInfo
|
||||
}
|
||||
if (rawModel.id === "anthropic/claude-fable-5") {
|
||||
const claudeFable1mModelInfo = cloneDeep(modelInfo)
|
||||
claudeFable1mModelInfo.contextWindow = 1_000_000
|
||||
@@ -319,8 +381,9 @@ async function fetchAndCacheClineModels(): Promise<Record<string, ModelInfo>> {
|
||||
if (Object.keys(models).length === 0) {
|
||||
throw new Error("No Cline models returned from API")
|
||||
}
|
||||
|
||||
// Save models and cache them in memory
|
||||
await fs.writeFile(clineModelsFilePath, JSON.stringify(models))
|
||||
await fs.writeFile(clineModelsFilePath, JSON.stringify(preferClineCanonicalModelIds(models)))
|
||||
Logger.log("Cline models fetched and saved")
|
||||
} catch (error) {
|
||||
Logger.error("Error fetching Cline models:", error)
|
||||
@@ -340,6 +403,7 @@ async function fetchAndCacheClineModels(): Promise<Record<string, ModelInfo>> {
|
||||
|
||||
// Avoid poisoning in-memory cache with an empty model map after transient failures.
|
||||
if (Object.keys(models).length > 0) {
|
||||
models = preferClineCanonicalModelIds(models)
|
||||
StateManager.get().setModelsCache("cline", models)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,31 @@ export interface ClineRecommendedModelsData {
|
||||
}
|
||||
|
||||
const RECOMMENDED_MODELS_CACHE_TTL_MS = 60 * 60 * 1000;
|
||||
const CLINE_PASS_MODEL_ID_ALIAS_RULES = [
|
||||
{ canonicalPrefix: "cline-pass/zai/", aliasPrefix: "cline-pass/z-ai/" },
|
||||
] as const;
|
||||
|
||||
function normalizeClineProviderRecommendedModelId(modelId: string): string {
|
||||
const zaiPrefix = "zai/";
|
||||
return modelId.startsWith(zaiPrefix) ? `z-ai/${modelId.slice(zaiPrefix.length)}` : modelId;
|
||||
}
|
||||
|
||||
function normalizeClineProviderRecommendedModels(
|
||||
models: ClineRecommendedModelData[],
|
||||
): ClineRecommendedModelData[] {
|
||||
return models.map((model) => {
|
||||
const id = normalizeClineProviderRecommendedModelId(model.id);
|
||||
if (id === model.id) {
|
||||
return model;
|
||||
}
|
||||
|
||||
return {
|
||||
...model,
|
||||
id,
|
||||
name: model.name === model.id ? id : model.name,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
let pendingRefresh: Promise<ClineRecommendedModelsData> | null = null;
|
||||
let inMemoryCache: {
|
||||
@@ -30,6 +55,26 @@ let inMemoryCache: {
|
||||
timestamp: number;
|
||||
} | null = null;
|
||||
|
||||
function preferCanonicalRecommendedModels(
|
||||
models: ClineRecommendedModelData[],
|
||||
): ClineRecommendedModelData[] {
|
||||
const modelIds = new Set(models.map((model) => model.id));
|
||||
return models.filter((model) => {
|
||||
for (const rule of CLINE_PASS_MODEL_ID_ALIAS_RULES) {
|
||||
if (!model.id.startsWith(rule.aliasPrefix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const canonicalModelId = `${rule.canonicalPrefix}${model.id.slice(rule.aliasPrefix.length)}`;
|
||||
if (modelIds.has(canonicalModelId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRecommendedModel(
|
||||
raw: unknown,
|
||||
): ClineRecommendedModelData | null {
|
||||
@@ -89,7 +134,11 @@ function normalizeRecommendedModelsResponse(
|
||||
.map((model) => normalizeRecommendedModel(model))
|
||||
.filter((model): model is ClineRecommendedModelData => model !== null);
|
||||
|
||||
return { recommended, free, clinePass };
|
||||
return {
|
||||
recommended: normalizeClineProviderRecommendedModels(recommended),
|
||||
free: normalizeClineProviderRecommendedModels(free),
|
||||
clinePass: preferCanonicalRecommendedModels(clinePass),
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshClineRecommendedModels(): Promise<ClineRecommendedModelsData> {
|
||||
@@ -161,14 +210,9 @@ async function fetchAndCacheClineRecommendedModels(): Promise<ClineRecommendedMo
|
||||
"utf8",
|
||||
);
|
||||
const parsed = JSON.parse(fileContents);
|
||||
if (parsed) {
|
||||
result = {
|
||||
recommended: Array.isArray(parsed.recommended)
|
||||
? parsed.recommended
|
||||
: [],
|
||||
free: Array.isArray(parsed.free) ? parsed.free : [],
|
||||
clinePass: Array.isArray(parsed.clinePass) ? parsed.clinePass : [],
|
||||
};
|
||||
const normalized = normalizeRecommendedModelsResponse(parsed);
|
||||
if (normalized) {
|
||||
result = normalized;
|
||||
Logger.log("Loaded Cline recommended models from cache");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,10 +12,12 @@ import {
|
||||
CLAUDE_OPUS_1M_TIERS,
|
||||
CLAUDE_SONNET_1M_TIERS,
|
||||
openRouterClaudeFable51mModelId,
|
||||
openRouterClaudeOpus51mModelId,
|
||||
openRouterClaudeOpus461mModelId,
|
||||
openRouterClaudeOpus471mModelId,
|
||||
openRouterClaudeOpus481mModelId,
|
||||
openRouterClaudeSonnet41mModelId,
|
||||
openRouterClaudeSonnet51mModelId,
|
||||
openRouterClaudeSonnet451mModelId,
|
||||
openRouterClaudeSonnet461mModelId,
|
||||
} from "@/shared/api"
|
||||
@@ -150,6 +152,16 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
|
||||
}
|
||||
|
||||
switch (rawModel.id) {
|
||||
case "anthropic/claude-sonnet-5":
|
||||
case "anthropic/claude-5-sonnet":
|
||||
// NOTE: we artificially restrict the context window to 200k to keep costs low for users, and have a :1m model variant created below for users that want to use the full 1m.
|
||||
modelInfo.contextWindow = 200_000
|
||||
modelInfo.supportsPromptCache = true
|
||||
modelInfo.inputPrice = 2.0
|
||||
modelInfo.outputPrice = 10.0
|
||||
modelInfo.cacheWritesPrice = 2.5
|
||||
modelInfo.cacheReadsPrice = 0.2
|
||||
break
|
||||
case "anthropic/claude-sonnet-4.6":
|
||||
case "anthropic/claude-4.6-sonnet":
|
||||
case "anthropic/claude-sonnet-4.5":
|
||||
@@ -173,6 +185,15 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
|
||||
modelInfo.cacheWritesPrice = 3.75
|
||||
modelInfo.cacheReadsPrice = 0.3
|
||||
break
|
||||
case "anthropic/claude-opus-5":
|
||||
case "anthropic/claude-5-opus":
|
||||
modelInfo.contextWindow = 200_000 // restrict to 200k, 1m variant created below
|
||||
modelInfo.supportsPromptCache = true
|
||||
modelInfo.inputPrice = 5.0
|
||||
modelInfo.outputPrice = 25.0
|
||||
modelInfo.cacheWritesPrice = 6.25
|
||||
modelInfo.cacheReadsPrice = 0.5
|
||||
break
|
||||
case "anthropic/claude-opus-4.6":
|
||||
case "anthropic/claude-opus-4.7":
|
||||
case "anthropic/claude-opus-4.8":
|
||||
@@ -294,11 +315,17 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
|
||||
rawModel.id === "anthropic/claude-sonnet-4.5" ||
|
||||
rawModel.id === "anthropic/claude-4.5-sonnet" ||
|
||||
rawModel.id === "anthropic/claude-sonnet-4.6" ||
|
||||
rawModel.id === "anthropic/claude-4.6-sonnet"
|
||||
rawModel.id === "anthropic/claude-4.6-sonnet" ||
|
||||
rawModel.id === "anthropic/claude-sonnet-5" ||
|
||||
rawModel.id === "anthropic/claude-5-sonnet"
|
||||
) {
|
||||
const claudeSonnet1mModelInfo = cloneDeep(modelInfo)
|
||||
claudeSonnet1mModelInfo.contextWindow = 1_000_000 // limiting providers to those that support 1m context window
|
||||
claudeSonnet1mModelInfo.tiers = CLAUDE_SONNET_1M_TIERS
|
||||
// sonnet 5
|
||||
if (rawModel.id === "anthropic/claude-sonnet-5" || rawModel.id === "anthropic/claude-5-sonnet") {
|
||||
models[openRouterClaudeSonnet51mModelId] = claudeSonnet1mModelInfo
|
||||
}
|
||||
// sonnet 4
|
||||
if (rawModel.id === "anthropic/claude-sonnet-4") {
|
||||
models[openRouterClaudeSonnet41mModelId] = claudeSonnet1mModelInfo
|
||||
@@ -332,6 +359,12 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
|
||||
models[openRouterClaudeOpus481mModelId] = claudeOpus1mModelInfo
|
||||
}
|
||||
}
|
||||
if (rawModel.id === "anthropic/claude-opus-5" || rawModel.id === "anthropic/claude-5-opus") {
|
||||
const claudeOpus51mModelInfo = cloneDeep(modelInfo)
|
||||
claudeOpus51mModelInfo.contextWindow = 1_000_000
|
||||
claudeOpus51mModelInfo.tiers = CLAUDE_OPUS_1M_TIERS
|
||||
models[openRouterClaudeOpus51mModelId] = claudeOpus51mModelInfo
|
||||
}
|
||||
if (rawModel.id === "anthropic/claude-fable-5") {
|
||||
const claudeFable1mModelInfo = cloneDeep(modelInfo)
|
||||
claudeFable1mModelInfo.contextWindow = 1_000_000
|
||||
|
||||
@@ -109,6 +109,12 @@ export async function updateApiConfigurationProto(
|
||||
protoApiConfiguration.planModeAihubmixModelInfo,
|
||||
)
|
||||
: undefined,
|
||||
planModeVertexCustomModelInfo:
|
||||
protoApiConfiguration.planModeVertexCustomModelInfo
|
||||
? fromProtobufModelInfo(
|
||||
protoApiConfiguration.planModeVertexCustomModelInfo,
|
||||
)
|
||||
: undefined,
|
||||
|
||||
// Act Mode
|
||||
actModeOpenRouterModelInfo:
|
||||
@@ -168,6 +174,12 @@ export async function updateApiConfigurationProto(
|
||||
protoApiConfiguration.actModeAihubmixModelInfo,
|
||||
)
|
||||
: undefined,
|
||||
actModeVertexCustomModelInfo:
|
||||
protoApiConfiguration.actModeVertexCustomModelInfo
|
||||
? fromProtobufModelInfo(
|
||||
protoApiConfiguration.actModeVertexCustomModelInfo,
|
||||
)
|
||||
: undefined,
|
||||
geminiPlanModeThinkingLevel:
|
||||
protoApiConfiguration.geminiPlanModeThinkingLevel,
|
||||
geminiActModeThinkingLevel:
|
||||
|
||||
@@ -19,19 +19,16 @@ describe("Hook System", () => {
|
||||
await writeHookScriptForPlatform(hookPath, nodeScript)
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
beforeEach(async function () {
|
||||
if (process.platform === "win32") {
|
||||
this.timeout(WINDOWS_TEST_TIMEOUT_MS)
|
||||
}
|
||||
setDistinctId("test-id")
|
||||
hookTestEnv = await createHookTestEnv()
|
||||
tempDir = hookTestEnv.tempDir
|
||||
sandbox = hookTestEnv.sandbox
|
||||
})
|
||||
|
||||
beforeEach(function () {
|
||||
if (process.platform === "win32") {
|
||||
this.timeout(WINDOWS_TEST_TIMEOUT_MS)
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await hookTestEnv.cleanup()
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import { PromptBuilder } from "../registry/PromptBuilder"
|
||||
import { SystemPromptSection } from "../templates/placeholders"
|
||||
import type { ComponentRegistry, PromptVariant, SystemPromptContext } from "../types"
|
||||
import { createVariant } from "../variants/variant-builder"
|
||||
import { mockProviderInfo } from "./integration.test"
|
||||
import { mockProviderInfo } from "./test-fixtures"
|
||||
|
||||
describe("PromptBuilder", () => {
|
||||
const mockContext: SystemPromptContext = {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { McpHub } from "@/services/mcp/McpHub"
|
||||
import { ModelFamily } from "@/shared/prompts"
|
||||
import { PromptRegistry } from "../registry/PromptRegistry"
|
||||
import type { SystemPromptContext } from "../types"
|
||||
import { mockProviderInfo } from "./integration.test"
|
||||
import { mockProviderInfo } from "./test-fixtures"
|
||||
|
||||
describe("PromptRegistry", () => {
|
||||
let registry: PromptRegistry
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect } from "chai"
|
||||
import type { McpHub } from "@/services/mcp/McpHub"
|
||||
import { TemplateEngine } from "../templates/TemplateEngine"
|
||||
import type { SystemPromptContext } from "../types"
|
||||
import { mockProviderInfo } from "./integration.test"
|
||||
import { mockProviderInfo } from "./test-fixtures"
|
||||
|
||||
describe("TemplateEngine", () => {
|
||||
let templateEngine: TemplateEngine
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { McpHub } from "@/services/mcp/McpHub"
|
||||
import { ModelFamily } from "@/shared/prompts"
|
||||
import { getSystemPrompt } from "../index"
|
||||
import type { SystemPromptContext } from "../types"
|
||||
import { mockProviderInfo } from "./test-fixtures"
|
||||
|
||||
// ============================================================================
|
||||
// Configuration
|
||||
@@ -111,12 +112,6 @@ async function assertSnapshot(name: string, content: string): Promise<void> {
|
||||
// Test Context Helpers
|
||||
// ============================================================================
|
||||
|
||||
export const mockProviderInfo = {
|
||||
providerId: "test",
|
||||
model: { id: "fast", info: { supportsPromptCache: false } },
|
||||
mode: "act" as const,
|
||||
}
|
||||
|
||||
const makeProviderInfo = (modelId: string, providerId = "test") => ({
|
||||
providerId: modelId.includes("ollama") ? "ollama" : providerId,
|
||||
model: { ...mockProviderInfo.model, id: modelId },
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ClineToolSet } from "../registry/ClineToolSet"
|
||||
import { PromptRegistry } from "../registry/PromptRegistry"
|
||||
import { new_task_variants } from "../tools/new_task"
|
||||
import type { SystemPromptContext } from "../types"
|
||||
import { mockProviderInfo } from "./integration.test"
|
||||
import { mockProviderInfo } from "./test-fixtures"
|
||||
|
||||
const baseContext: SystemPromptContext = {
|
||||
cwd: "/test/project",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// Shared provider info fixture used across the system-prompt test suite.
|
||||
// Kept in a non-`.test.ts` file (like matcher-test.ts) so it can be exported
|
||||
// and imported by sibling test files without tripping biome's noExportsInTest rule.
|
||||
export const mockProviderInfo = {
|
||||
providerId: "test",
|
||||
model: { id: "fast", info: { supportsPromptCache: false } },
|
||||
mode: "act" as const,
|
||||
}
|
||||
@@ -60,7 +60,7 @@ describe("fetchRemoteConfig", () => {
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
it("clears remote config and skips discovery when Cline Pass is selected", async () => {
|
||||
it("clears remote config and skips discovery when ClinePass is selected", async () => {
|
||||
const controller = createController({
|
||||
stateManager: {
|
||||
getApiConfiguration: sandbox.stub().returns({
|
||||
|
||||
@@ -87,7 +87,6 @@ import {
|
||||
} from "@shared/Languages";
|
||||
import { USER_CONTENT_TAGS } from "@shared/messages/constants";
|
||||
import { convertClineMessageToProto } from "@shared/proto-conversions/cline-message";
|
||||
import { FeatureFlag } from "@shared/services/feature-flags/feature-flags";
|
||||
import { type ClineDefaultTool, READ_ONLY_TOOLS } from "@shared/tools";
|
||||
import type { ClineAskResponse } from "@shared/WebviewMessage";
|
||||
import {
|
||||
@@ -2039,11 +2038,7 @@ export class Task {
|
||||
? apiConfig.planModeApiProvider
|
||||
: apiConfig.actModeApiProvider
|
||||
) as string;
|
||||
const providerId =
|
||||
configuredProviderId === "cline-pass" &&
|
||||
!featureFlagsService.getBooleanFlagEnabled(FeatureFlag.CLINE_PASS)
|
||||
? "cline"
|
||||
: configuredProviderId;
|
||||
const providerId = configuredProviderId;
|
||||
const customPrompt = this.stateManager.getGlobalSettingsKey("customPrompt");
|
||||
return { model, providerId, customPrompt, mode };
|
||||
}
|
||||
@@ -2469,6 +2464,14 @@ export class Task {
|
||||
const isEntitlementError = clineError.isErrorType(
|
||||
ClineErrorType.Entitlement,
|
||||
);
|
||||
const isOrgClinePassRestrictionError = clineError.isErrorType(
|
||||
ClineErrorType.OrgClinePassRestriction,
|
||||
);
|
||||
// ClinePass period limits reset in hours/days — auto-retrying is
|
||||
// pointless and only delays the actionable error UI.
|
||||
const isClinePassLimitError = clineError.isErrorType(
|
||||
ClineErrorType.ClinePassLimit,
|
||||
);
|
||||
|
||||
// Check if this is a Cline provider insufficient credits error - don't auto-retry these
|
||||
const isClineProviderInsufficientCredits = (() => {
|
||||
@@ -2495,6 +2498,8 @@ export class Task {
|
||||
!isSpendLimitError &&
|
||||
!quotaExceeded &&
|
||||
!isEntitlementError &&
|
||||
!isOrgClinePassRestrictionError &&
|
||||
!isClinePassLimitError &&
|
||||
this.taskState.autoRetryAttempts < 3;
|
||||
if (shouldRetry) {
|
||||
// Auto-retry enabled with max 3 attempts: automatically approve the retry
|
||||
@@ -2557,7 +2562,9 @@ export class Task {
|
||||
!isAuthError &&
|
||||
!isSpendLimitError &&
|
||||
!quotaExceeded &&
|
||||
!isEntitlementError;
|
||||
!isEntitlementError &&
|
||||
!isOrgClinePassRestrictionError &&
|
||||
!isClinePassLimitError;
|
||||
if (showRetry) {
|
||||
await this.say(
|
||||
"error_retry",
|
||||
@@ -2820,6 +2827,16 @@ export class Task {
|
||||
this.taskState.consecutiveMistakeCount >=
|
||||
this.stateManager.getGlobalSettingsKey("maxConsecutiveMistakes")
|
||||
) {
|
||||
telemetryService.captureMistakeLimitReached({
|
||||
ulid: this.ulid,
|
||||
model: model.id,
|
||||
provider: providerId,
|
||||
consecutiveMistakes: this.taskState.consecutiveMistakeCount,
|
||||
maxConsecutiveMistakes: this.stateManager.getGlobalSettingsKey(
|
||||
"maxConsecutiveMistakes",
|
||||
),
|
||||
yoloMode: this.stateManager.getGlobalSettingsKey("yoloModeToggled"),
|
||||
});
|
||||
// In yolo mode, don't wait for user input - fail the task
|
||||
if (this.stateManager.getGlobalSettingsKey("yoloModeToggled")) {
|
||||
const errorMessage =
|
||||
@@ -2842,9 +2859,7 @@ export class Task {
|
||||
}
|
||||
const { response, text, images, files } = await this.ask(
|
||||
"mistake_limit_reached",
|
||||
this.api.getModel().id.includes("claude")
|
||||
? `This may indicate a failure in Cline's thought process or inability to use a tool properly, which can be mitigated with some user guidance (e.g. "Try breaking down the task into smaller steps").`
|
||||
: "Cline uses complex prompts and iterative task execution that may be challenging for less capable models. For best results, it's recommended to use Claude 4.5 Sonnet for its advanced agentic coding capabilities.",
|
||||
`Cline hit repeated tool call failures. Try guiding it with a new prompt.`,
|
||||
);
|
||||
if (response === "messageResponse") {
|
||||
// Display the user's message in the chat UI
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -53,10 +53,15 @@ import { ExtensionRegistryInfo } from "./registry"
|
||||
import { AuthService } from "./services/auth/AuthService"
|
||||
import { LogoutReason } from "./services/auth/types"
|
||||
import { telemetryService } from "./services/telemetry"
|
||||
import type { RolloutBundleActivation } from "./services/telemetry/rollout-metadata"
|
||||
import { LG_TASK_URI_PATH, SharedUriHandler, TASK_URI_PATH } from "./services/uri/SharedUriHandler"
|
||||
import { ShowMessageType } from "./shared/proto/host/window"
|
||||
import { fileExistsAtPath } from "./utils/fs"
|
||||
|
||||
export async function reportRolloutActivation(input: RolloutBundleActivation): Promise<void> {
|
||||
await telemetryService.captureRolloutBundleActivated(input)
|
||||
}
|
||||
|
||||
// This method is called when the VS Code extension is activated.
|
||||
// NOTE: This is VS Code specific - services that should be registered
|
||||
// for all-platform should be registered in common.ts.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { serializeError } from "serialize-error"
|
||||
import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "../../shared/ClineAccount"
|
||||
import { serializeError } from "serialize-error";
|
||||
import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "../../shared/ClineAccount";
|
||||
|
||||
export enum ClineErrorType {
|
||||
Auth = "auth",
|
||||
@@ -9,46 +9,98 @@ export enum ClineErrorType {
|
||||
SpendLimit = "spendLimit",
|
||||
QuotaExceeded = "quotaExceeded",
|
||||
Entitlement = "entitlement",
|
||||
OrgClinePassRestriction = "orgClinePassRestriction",
|
||||
ClinePassLimit = "clinePassLimit",
|
||||
}
|
||||
|
||||
interface ErrorDetails {
|
||||
/**
|
||||
* The HTTP status code of the error, if applicable.
|
||||
*/
|
||||
status?: number
|
||||
status?: number;
|
||||
/**
|
||||
* The request ID associated with the error, if available.
|
||||
* This can be useful for debugging and support.
|
||||
*/
|
||||
request_id?: string
|
||||
request_id?: string;
|
||||
/**
|
||||
* Specific error code provided by the API or service.
|
||||
*/
|
||||
code?: string
|
||||
code?: string;
|
||||
/**
|
||||
* The model ID associated with the error, if applicable.
|
||||
* This is useful for identifying which model the error relates to.
|
||||
*/
|
||||
modelId?: string
|
||||
modelId?: string;
|
||||
/**
|
||||
* The provider ID associated with the error, if applicable.
|
||||
* This is useful for identifying which provider the error relates to.
|
||||
*/
|
||||
providerId?: string
|
||||
providerId?: string;
|
||||
/**
|
||||
* The error message associated with the error, if applicable.
|
||||
*/
|
||||
message?: string
|
||||
message?: string;
|
||||
// Additional details that might be present in the error
|
||||
// This can include things like current balance, error messages, etc.
|
||||
details?: any
|
||||
details?: any;
|
||||
}
|
||||
|
||||
const RATE_LIMIT_PATTERNS = [/status code 429/i, /rate limit/i, /too many requests/i, /quota exceeded/i, /resource exhausted/i]
|
||||
const RATE_LIMIT_PATTERNS = [
|
||||
/status code 429/i,
|
||||
/rate limit/i,
|
||||
/too many requests/i,
|
||||
/quota exceeded/i,
|
||||
/resource exhausted/i,
|
||||
];
|
||||
const ORG_CLINE_PASS_RESTRICTION_MESSAGE =
|
||||
"organization accounts cannot use individual model inference subscriptions";
|
||||
const ORG_CLINE_PASS_RESTRICTION_USER_MESSAGE =
|
||||
"organization accounts cannot use clinepass subscriptions";
|
||||
|
||||
// The ClinePass period limit message is dynamic ("weekly"/"5-hour" period, "7d"/"12h"
|
||||
// reset), so it is matched by its fixed prefix/suffix with the ClinePass marker in
|
||||
// between. Plain indexOf scanning — no regex, so no backtracking on hostile input.
|
||||
const CLINE_PASS_LIMIT_PREFIX = "you have reached your";
|
||||
const CLINE_PASS_LIMIT_MARKER = "clinepass limit";
|
||||
const CLINE_PASS_LIMIT_SUFFIX = "please try again later.";
|
||||
|
||||
function findClinePassLimitMessageBounds(
|
||||
text: string,
|
||||
): { start: number; end: number } | undefined {
|
||||
const normalized = text.toLowerCase();
|
||||
const start = normalized.indexOf(CLINE_PASS_LIMIT_PREFIX);
|
||||
if (start === -1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const suffixStart = normalized.indexOf(CLINE_PASS_LIMIT_SUFFIX, start);
|
||||
if (suffixStart === -1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const end = suffixStart + CLINE_PASS_LIMIT_SUFFIX.length;
|
||||
if (!normalized.slice(start, end).includes(CLINE_PASS_LIMIT_MARKER)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
export function isClinePassLimitMessage(text: string): boolean {
|
||||
return findClinePassLimitMessageBounds(text) !== undefined;
|
||||
}
|
||||
|
||||
export function extractClinePassLimitMessage(
|
||||
text: string,
|
||||
): string | undefined {
|
||||
const bounds = findClinePassLimitMessageBounds(text);
|
||||
return bounds ? text.slice(bounds.start, bounds.end) : undefined;
|
||||
}
|
||||
|
||||
export class ClineError extends Error {
|
||||
readonly title = "ClineError"
|
||||
readonly _error: ErrorDetails
|
||||
readonly title = "ClineError";
|
||||
readonly _error: ErrorDetails;
|
||||
|
||||
// Error details per providers:
|
||||
// Cline: error?.error
|
||||
@@ -59,15 +111,19 @@ export class ClineError extends Error {
|
||||
public readonly modelId?: string,
|
||||
public readonly providerId?: string,
|
||||
) {
|
||||
const error = serializeError(raw)
|
||||
const error = serializeError(raw);
|
||||
|
||||
const message = error.message || error?.response?.message || String(error) || error?.cause?.means
|
||||
super(message)
|
||||
const message =
|
||||
error.message ||
|
||||
error?.response?.message ||
|
||||
String(error) ||
|
||||
error?.cause?.means;
|
||||
super(message);
|
||||
|
||||
// Extract status from multiple possible locations
|
||||
const status = error.status || error.statusCode || error.response?.status
|
||||
this.modelId = modelId || error.modelId
|
||||
this.providerId = providerId || error.providerId
|
||||
const status = error.status || error.statusCode || error.response?.status;
|
||||
this.modelId = modelId || error.modelId;
|
||||
this.providerId = providerId || error.providerId;
|
||||
|
||||
// Construct the error details object to includes relevant information
|
||||
// And ensure it has a consistent structure
|
||||
@@ -85,7 +141,7 @@ export class ClineError extends Error {
|
||||
providerId: this.providerId,
|
||||
details: error.details || error.error, // Additional details provided by the server
|
||||
stack: undefined, // Avoid serializing stack trace to keep the error object clean
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,7 +157,7 @@ export class ClineError extends Error {
|
||||
modelId: this.modelId,
|
||||
providerId: this.providerId,
|
||||
details: this._error.details,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,29 +165,33 @@ export class ClineError extends Error {
|
||||
*/
|
||||
static parse(errorStr?: string, modelId?: string): ClineError | undefined {
|
||||
if (!errorStr || typeof errorStr !== "string") {
|
||||
return undefined
|
||||
return undefined;
|
||||
}
|
||||
return ClineError.transform(errorStr, modelId)
|
||||
return ClineError.transform(errorStr, modelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms any object into a ClineError instance.
|
||||
* Always returns a ClineError, even if the input is not a valid error object.
|
||||
*/
|
||||
static transform(error: any, modelId?: string, providerId?: string): ClineError {
|
||||
static transform(
|
||||
error: any,
|
||||
modelId?: string,
|
||||
providerId?: string,
|
||||
): ClineError {
|
||||
try {
|
||||
// If already a ClineError, return it directly to prevent infinite recursion
|
||||
if (error instanceof ClineError) {
|
||||
return error
|
||||
return error;
|
||||
}
|
||||
return new ClineError(JSON.parse(error), modelId, providerId)
|
||||
return new ClineError(JSON.parse(error), modelId, providerId);
|
||||
} catch {
|
||||
return new ClineError(error, modelId, providerId)
|
||||
return new ClineError(error, modelId, providerId);
|
||||
}
|
||||
}
|
||||
|
||||
public isErrorType(type: ClineErrorType): boolean {
|
||||
return ClineError.getErrorType(this) === type
|
||||
return ClineError.getErrorType(this) === type;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,53 +199,98 @@ export class ClineError extends Error {
|
||||
* This is useful for determining how to handle the error in the UI or logic.
|
||||
*/
|
||||
static getErrorType(err: ClineError): ClineErrorType | undefined {
|
||||
const { code, status, details } = err._error
|
||||
const message = (err._error?.message || err.message || JSON.stringify(err._error))?.toLowerCase()
|
||||
const { code, status, details } = err._error;
|
||||
const message = (
|
||||
err._error?.message ||
|
||||
err.message ||
|
||||
JSON.stringify(err._error)
|
||||
)?.toLowerCase();
|
||||
|
||||
// Check balance error first (most specific)
|
||||
if (code === "insufficient_credits" && typeof details?.current_balance === "number") {
|
||||
return ClineErrorType.Balance
|
||||
if (
|
||||
code === "insufficient_credits" &&
|
||||
typeof details?.current_balance === "number"
|
||||
) {
|
||||
return ClineErrorType.Balance;
|
||||
}
|
||||
|
||||
// Check spend limit exceeded (org-enforced budget cap, 429 SPEND_LIMIT_EXCEEDED)
|
||||
// Must be checked before the generic rate-limit check since both use 429
|
||||
if (code === "SPEND_LIMIT_EXCEEDED" || details?.code === "SPEND_LIMIT_EXCEEDED") {
|
||||
return ClineErrorType.SpendLimit
|
||||
if (
|
||||
code === "SPEND_LIMIT_EXCEEDED" ||
|
||||
details?.code === "SPEND_LIMIT_EXCEEDED"
|
||||
) {
|
||||
return ClineErrorType.SpendLimit;
|
||||
}
|
||||
|
||||
// Scoped to the individual "not subscribed" case; other ENTITLEMENT_ERROR variants (e.g. org
|
||||
// accounts) fall through. Checked before the generic auth check since these are returned as 403.
|
||||
const isEntitlementCode = code === "ENTITLEMENT_ERROR" || details?.code === "ENTITLEMENT_ERROR"
|
||||
const entitlementText = `${message ?? ""} ${details?.message ?? ""}`.toLowerCase()
|
||||
if (isEntitlementCode && entitlementText.includes("not subscribed to required model plan")) {
|
||||
return ClineErrorType.Entitlement
|
||||
// ClinePass entitlement errors are user-actionable and should not fall through to generic 403 auth.
|
||||
// The organization-account variant gets separate copy because subscribing is not the right action.
|
||||
const isEntitlementCode =
|
||||
code === "ENTITLEMENT_ERROR" || details?.code === "ENTITLEMENT_ERROR";
|
||||
const entitlementText =
|
||||
`${message ?? ""} ${details?.message ?? ""}`.toLowerCase();
|
||||
if (
|
||||
isEntitlementCode &&
|
||||
(entitlementText.includes(ORG_CLINE_PASS_RESTRICTION_MESSAGE) ||
|
||||
entitlementText.includes(ORG_CLINE_PASS_RESTRICTION_USER_MESSAGE))
|
||||
) {
|
||||
return ClineErrorType.OrgClinePassRestriction;
|
||||
}
|
||||
if (
|
||||
isEntitlementCode &&
|
||||
entitlementText.includes("not subscribed to required model plan")
|
||||
) {
|
||||
return ClineErrorType.Entitlement;
|
||||
}
|
||||
|
||||
// ClinePass period limits (weekly/5-hour) are user-actionable (switch to
|
||||
// usage-based billing) and must not fall through to the generic 403 auth
|
||||
// handling below or the 429 rate-limit patterns.
|
||||
const detailMessage =
|
||||
typeof details?.message === "string" ? details.message : undefined;
|
||||
if (
|
||||
isClinePassLimitMessage(detailMessage ?? "") ||
|
||||
isClinePassLimitMessage(message ?? "")
|
||||
) {
|
||||
return ClineErrorType.ClinePassLimit;
|
||||
}
|
||||
|
||||
// Check auth errors
|
||||
const isAuthStatus = status !== undefined && status > 400 && status < 429
|
||||
if (code === "ERR_BAD_REQUEST" || err instanceof AuthInvalidTokenError || isAuthStatus) {
|
||||
return ClineErrorType.Auth
|
||||
const isAuthStatus = status !== undefined && status > 400 && status < 429;
|
||||
if (
|
||||
code === "ERR_BAD_REQUEST" ||
|
||||
err instanceof AuthInvalidTokenError ||
|
||||
isAuthStatus
|
||||
) {
|
||||
return ClineErrorType.Auth;
|
||||
}
|
||||
|
||||
if (code === "INFERENCE_CAP_ERROR") {
|
||||
return ClineErrorType.QuotaExceeded
|
||||
return ClineErrorType.QuotaExceeded;
|
||||
}
|
||||
|
||||
if (message) {
|
||||
// Check for specific error codes/messages if applicable
|
||||
const authErrorRegex = [/(?:in)?valid[-_ ]?(?:api )?(?:token|key)/i, /authentication[-_ ]?failed/i, /unauthorized/i]
|
||||
if (message?.includes(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE) || authErrorRegex.some((regex) => regex.test(message))) {
|
||||
return ClineErrorType.Auth
|
||||
const authErrorRegex = [
|
||||
/(?:in)?valid[-_ ]?(?:api )?(?:token|key)/i,
|
||||
/authentication[-_ ]?failed/i,
|
||||
/unauthorized/i,
|
||||
];
|
||||
if (
|
||||
message?.includes(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE) ||
|
||||
authErrorRegex.some((regex) => regex.test(message))
|
||||
) {
|
||||
return ClineErrorType.Auth;
|
||||
}
|
||||
|
||||
// Check rate limit patterns
|
||||
const lowerMessage = message.toLowerCase()
|
||||
const lowerMessage = message.toLowerCase();
|
||||
if (RATE_LIMIT_PATTERNS.some((pattern) => pattern.test(lowerMessage))) {
|
||||
return ClineErrorType.RateLimit
|
||||
return ClineErrorType.RateLimit;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,14 +299,14 @@ export class AuthNetworkError extends Error {
|
||||
message: string,
|
||||
override readonly cause?: Error,
|
||||
) {
|
||||
super(message)
|
||||
this.name = ClineErrorType.Network
|
||||
super(message);
|
||||
this.name = ClineErrorType.Network;
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthInvalidTokenError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = ClineErrorType.Auth
|
||||
super(message);
|
||||
this.name = ClineErrorType.Auth;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,57 @@
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import { ClineError, ClineErrorType } from "../ClineError"
|
||||
import { describe, it } from "mocha";
|
||||
import "should";
|
||||
import {
|
||||
ClineError,
|
||||
ClineErrorType,
|
||||
extractClinePassLimitMessage,
|
||||
isClinePassLimitMessage,
|
||||
} from "../ClineError";
|
||||
|
||||
describe("ClineError", () => {
|
||||
describe("getErrorType", () => {
|
||||
it("should return QuotaExceeded when code is INFERENCE_CAP_ERROR", () => {
|
||||
const err = new ClineError({ message: "Inference cap reached", code: "INFERENCE_CAP_ERROR" })
|
||||
ClineError.getErrorType(err)!.should.equal(ClineErrorType.QuotaExceeded)
|
||||
})
|
||||
const err = new ClineError({
|
||||
message: "Inference cap reached",
|
||||
code: "INFERENCE_CAP_ERROR",
|
||||
});
|
||||
ClineError.getErrorType(err)!.should.equal(ClineErrorType.QuotaExceeded);
|
||||
});
|
||||
|
||||
it("should return Entitlement when code is ENTITLEMENT_ERROR", () => {
|
||||
const err = new ClineError({
|
||||
message: "403 Error 403: the user is not subscribed to required model plan",
|
||||
message:
|
||||
"403 Error 403: the user is not subscribed to required model plan",
|
||||
code: "ENTITLEMENT_ERROR",
|
||||
status: 403,
|
||||
})
|
||||
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
|
||||
})
|
||||
});
|
||||
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement);
|
||||
});
|
||||
|
||||
it("should return Entitlement when details.code is ENTITLEMENT_ERROR", () => {
|
||||
const err = new ClineError({
|
||||
message: "403 Error 403: the user is not subscribed to required model plan",
|
||||
message:
|
||||
"403 Error 403: the user is not subscribed to required model plan",
|
||||
status: 403,
|
||||
details: { code: "ENTITLEMENT_ERROR", message: "Error 403: the user is not subscribed to required model plan" },
|
||||
})
|
||||
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
|
||||
})
|
||||
details: {
|
||||
code: "ENTITLEMENT_ERROR",
|
||||
message:
|
||||
"Error 403: the user is not subscribed to required model plan",
|
||||
},
|
||||
});
|
||||
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement);
|
||||
});
|
||||
|
||||
it("should prefer Entitlement over Auth for 403 ENTITLEMENT_ERROR", () => {
|
||||
// status 403 would otherwise be classified as Auth; the entitlement code must win.
|
||||
const err = new ClineError({
|
||||
message: "403 Error 403: the user is not subscribed to required model plan",
|
||||
message:
|
||||
"403 Error 403: the user is not subscribed to required model plan",
|
||||
code: "ENTITLEMENT_ERROR",
|
||||
status: 403,
|
||||
})
|
||||
ClineError.getErrorType(err)!.should.not.equal(ClineErrorType.Auth)
|
||||
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
|
||||
})
|
||||
});
|
||||
ClineError.getErrorType(err)!.should.not.equal(ClineErrorType.Auth);
|
||||
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement);
|
||||
});
|
||||
|
||||
it("should return Entitlement for the real Cline 403 provider error shape (nested error object)", () => {
|
||||
// ClineError maps `error.error` into `details`, so `details.code` drives classification.
|
||||
@@ -45,25 +60,108 @@ describe("ClineError", () => {
|
||||
status: 403,
|
||||
error: {
|
||||
code: "ENTITLEMENT_ERROR",
|
||||
message: "Error 403: the user is not subscribed to required model plan",
|
||||
message:
|
||||
"Error 403: the user is not subscribed to required model plan",
|
||||
},
|
||||
},
|
||||
"cline-pass/glm-5.1",
|
||||
"cline-pass/glm-5.2",
|
||||
"cline-pass",
|
||||
)
|
||||
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
|
||||
})
|
||||
);
|
||||
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement);
|
||||
});
|
||||
|
||||
it("should NOT classify the organization ENTITLEMENT_ERROR variant as Entitlement", () => {
|
||||
// Org accounts can't use individual subs; this case is intentionally out of scope and
|
||||
// falls through to generic handling rather than showing the ClinePass card.
|
||||
it("should classify the organization ENTITLEMENT_ERROR variant separately from the ClinePass subscription card", () => {
|
||||
// Org accounts can't use individual subs; this case should not show the personal ClinePass
|
||||
// subscription card, but it should still get dedicated user-actionable copy.
|
||||
const err = new ClineError({
|
||||
message: "403 Error 403: organization accounts cannot use individual model inference subscriptions",
|
||||
message:
|
||||
"403 Error 403: organization accounts cannot use individual model inference subscriptions",
|
||||
code: "ENTITLEMENT_ERROR",
|
||||
status: 403,
|
||||
})
|
||||
const result = ClineError.getErrorType(err)
|
||||
;(result !== ClineErrorType.Entitlement).should.be.true()
|
||||
})
|
||||
})
|
||||
})
|
||||
});
|
||||
const result = ClineError.getErrorType(err);
|
||||
result!.should.equal(ClineErrorType.OrgClinePassRestriction);
|
||||
(result !== ClineErrorType.Entitlement).should.be.true();
|
||||
});
|
||||
|
||||
it("should not classify organization restriction text without ENTITLEMENT_ERROR as OrgClinePassRestriction", () => {
|
||||
const err = new ClineError({
|
||||
message:
|
||||
"Network error: organization accounts cannot use individual model inference subscriptions",
|
||||
code: "ERR_NETWORK",
|
||||
});
|
||||
|
||||
const result = ClineError.getErrorType(err);
|
||||
(result !== ClineErrorType.OrgClinePassRestriction).should.be.true();
|
||||
});
|
||||
|
||||
it("should classify ClinePass period limit messages as ClinePassLimit", () => {
|
||||
const err = new ClineError(
|
||||
"You have reached your weekly Clinepass limit. The limit resets in 7d, please try again later.",
|
||||
);
|
||||
|
||||
ClineError.getErrorType(err)!.should.equal(ClineErrorType.ClinePassLimit);
|
||||
});
|
||||
|
||||
it("should classify nested ClinePass period limit messages as ClinePassLimit", () => {
|
||||
// ClineError maps `error.error` into `details`, matching the real provider error shape.
|
||||
const err = new ClineError({
|
||||
message: "403 Error 403",
|
||||
error: {
|
||||
message:
|
||||
"You have reached your monthly ClinePass limit. The limit resets in 12h, please try again later.",
|
||||
},
|
||||
});
|
||||
|
||||
ClineError.getErrorType(err)!.should.equal(ClineErrorType.ClinePassLimit);
|
||||
});
|
||||
|
||||
it("should prefer ClinePassLimit over Auth for a 403 with the limit message", () => {
|
||||
// status 403 falls inside the generic auth-status range; the limit message must win.
|
||||
const err = new ClineError({
|
||||
message:
|
||||
"You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.",
|
||||
status: 403,
|
||||
});
|
||||
|
||||
ClineError.getErrorType(err)!.should.equal(ClineErrorType.ClinePassLimit);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isClinePassLimitMessage", () => {
|
||||
it("matches limit messages with variable period and reset", () => {
|
||||
isClinePassLimitMessage(
|
||||
"You have reached your weekly Clinepass limit. The limit resets in 7d, please try again later.",
|
||||
).should.be.true();
|
||||
isClinePassLimitMessage(
|
||||
"You have reached your 5-hour ClinePass limit. The limit resets in 5h, please try again later.",
|
||||
).should.be.true();
|
||||
});
|
||||
|
||||
it("does not match unrelated or partial messages", () => {
|
||||
isClinePassLimitMessage(
|
||||
"the user is not subscribed to required model plan",
|
||||
).should.be.false();
|
||||
isClinePassLimitMessage(
|
||||
`You have reached your\t-\tClinepass limit.The limit resets in\t${"\t".repeat(10_000)}`,
|
||||
).should.be.false();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractClinePassLimitMessage", () => {
|
||||
it("extracts the limit message out of a wrapped error string", () => {
|
||||
const message =
|
||||
"You have reached your weekly Clinepass limit. The limit resets in 7d, please try again later.";
|
||||
|
||||
extractClinePassLimitMessage(`429 Error: ${message}`)!.should.equal(
|
||||
message,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined when there is no limit message", () => {
|
||||
(
|
||||
extractClinePassLimitMessage("some other error") === undefined
|
||||
).should.be.true();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,6 +98,49 @@ describe("Telemetry system is abstracted and can easily switch between providers
|
||||
await localService.dispose()
|
||||
})
|
||||
|
||||
it("should derive host_plugin_version from the host's clineVersion", async () => {
|
||||
const cases = [
|
||||
{
|
||||
hostVersion: {
|
||||
platform: "IntelliJ IDEA Ultimate",
|
||||
version: "2026.1.1",
|
||||
clineType: "Cline for JetBrains",
|
||||
clineVersion: "1.1.61",
|
||||
},
|
||||
expectedHostPluginVersion: "1.1.61" as string | undefined,
|
||||
},
|
||||
{
|
||||
hostVersion: {
|
||||
platform: "VS Code",
|
||||
version: "1.103.0",
|
||||
clineType: "VSCode Extension",
|
||||
},
|
||||
expectedHostPluginVersion: undefined,
|
||||
},
|
||||
]
|
||||
|
||||
for (const { hostVersion, expectedHostPluginVersion } of cases) {
|
||||
const sandbox = sinon.createSandbox()
|
||||
let service: TelemetryService | undefined
|
||||
try {
|
||||
const provider = new NoOpTelemetryProvider()
|
||||
const logSpy = sandbox.spy(provider, "log")
|
||||
sandbox.stub(HostProvider.env, "getHostVersion").resolves(hostVersion)
|
||||
sandbox.stub(TelemetryProviderFactory, "createProviders").resolves([provider])
|
||||
|
||||
service = await TelemetryService.create()
|
||||
logSpy.resetHistory()
|
||||
service.captureTaskCreated("task-123", "openai")
|
||||
|
||||
assert.ok(logSpy.calledOnce, `service should emit an event (${hostVersion.clineType})`)
|
||||
assert.strictEqual(logSpy.firstCall.args[1]?.host_plugin_version, expectedHostPluginVersion)
|
||||
} finally {
|
||||
sandbox.restore()
|
||||
await service?.dispose()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("should include remote workspace metadata on workspace.initialized events", async () => {
|
||||
const noOpProvider = new NoOpTelemetryProvider()
|
||||
const logSpy = sinon.spy(noOpProvider, "log")
|
||||
|
||||
@@ -11,6 +11,13 @@ import { Mode } from "@/shared/storage/types"
|
||||
import { version as extensionVersion } from "../../../package.json"
|
||||
import { setDistinctId } from "../logging/distinctId"
|
||||
import type { ITelemetryProvider, TelemetryProperties } from "./providers/ITelemetryProvider"
|
||||
import {
|
||||
getRolloutErrorProperties,
|
||||
getRolloutTelemetryMetadata,
|
||||
ROLLOUT_BUNDLE_ACTIVATED_EVENT,
|
||||
type RolloutBundleActivation,
|
||||
type RolloutTelemetryMetadata,
|
||||
} from "./rollout-metadata"
|
||||
import { TelemetryProviderFactory } from "./TelemetryProviderFactory"
|
||||
|
||||
/**
|
||||
@@ -80,6 +87,12 @@ export type TelemetryMetadata = {
|
||||
* all use the same extension or plugin.
|
||||
*/
|
||||
cline_type: string
|
||||
/**
|
||||
* The version of the host-side Cline distribution package: the JetBrains plugin version
|
||||
* (e.g. 1.1.61) on JetBrains, the extension version on VSCode (where it matches
|
||||
* `extension_version`). Absent when the host does not report one (e.g. CLI).
|
||||
*/
|
||||
host_plugin_version?: string
|
||||
/** The name of the host IDE or environment e.g. VSCode, Cursor, IntelliJ Professional Edition, etc. */
|
||||
platform: string
|
||||
/** The version of the host environment */
|
||||
@@ -93,6 +106,8 @@ export type TelemetryMetadata = {
|
||||
is_remote_workspace: boolean
|
||||
/** Whether the extension is running in development mode */
|
||||
is_dev: string | undefined
|
||||
/** Present only in bundles built by the combined legacy/next rollout workflow. */
|
||||
extension_variant?: RolloutTelemetryMetadata["extension_variant"]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -264,6 +279,8 @@ export class TelemetryService {
|
||||
GEMINI_API_PERFORMANCE: "task.gemini_api_performance",
|
||||
// Tracks when API providers return errors
|
||||
PROVIDER_API_ERROR: "task.provider_api_error",
|
||||
// Tracks when the consecutive mistake limit is reached
|
||||
MISTAKE_LIMIT_REACHED: "task.mistake_limit_reached",
|
||||
// Tracks when users enable the focus chain feature
|
||||
FOCUS_CHAIN_ENABLED: "task.focus_chain_enabled",
|
||||
// Tracks when users disable the focus chain feature
|
||||
@@ -357,6 +374,7 @@ export class TelemetryService {
|
||||
const hostVersion = await HostProvider.env.getHostVersion({})
|
||||
const metadata: TelemetryMetadata = {
|
||||
extension_version: extensionVersion,
|
||||
...(hostVersion.clineVersion ? { host_plugin_version: hostVersion.clineVersion } : {}),
|
||||
platform: hostVersion.platform || "unknown",
|
||||
platform_version: hostVersion.version || "unknown",
|
||||
cline_type: hostVersion.clineType || "unknown",
|
||||
@@ -365,6 +383,7 @@ export class TelemetryService {
|
||||
// `remoteName` is normalized by the host bridge to `undefined` for local workspaces.
|
||||
is_remote_workspace: !!hostVersion.remoteName,
|
||||
is_dev: process.env.IS_DEV,
|
||||
...getRolloutTelemetryMetadata(),
|
||||
}
|
||||
return new TelemetryService(providers, metadata)
|
||||
}
|
||||
@@ -566,6 +585,22 @@ export class TelemetryService {
|
||||
})
|
||||
}
|
||||
|
||||
public captureRolloutBundleActivated(input: RolloutBundleActivation): void {
|
||||
if (!this.telemetryMetadata.extension_variant) {
|
||||
return
|
||||
}
|
||||
|
||||
this.capture({
|
||||
event: ROLLOUT_BUNDLE_ACTIVATED_EVENT,
|
||||
properties: {
|
||||
attempted_bundle: input.attemptedBundle,
|
||||
actual_bundle: input.actualBundle,
|
||||
fallback: input.fallback,
|
||||
...(input.fallback ? getRolloutErrorProperties(input.error) : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
public captureExtensionStorageError(errorMessage: string, eventName: string) {
|
||||
// Truncate error message to prevent excessive data
|
||||
this.capture({
|
||||
@@ -1400,6 +1435,33 @@ export class TelemetryService {
|
||||
this.recordHistogram(TelemetryService.METRICS.ERRORS.PER_TASK, errorCount, errorAttributes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when the consecutive mistake limit is reached, right before the
|
||||
* limit decision (user prompt / yolo auto-stop) is resolved
|
||||
* @param ulid Unique identifier for the task
|
||||
* @param model Identifier of the model used
|
||||
* @param provider Identifier of the API provider
|
||||
* @param consecutiveMistakes Number of consecutive mistakes when the limit was hit
|
||||
* @param maxConsecutiveMistakes The configured mistake limit
|
||||
* @param yoloMode Whether yolo mode auto-failed the task instead of asking the user
|
||||
*/
|
||||
public captureMistakeLimitReached(args: {
|
||||
ulid: string
|
||||
model: string
|
||||
provider?: string
|
||||
consecutiveMistakes: number
|
||||
maxConsecutiveMistakes: number
|
||||
yoloMode?: boolean
|
||||
}) {
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.TASK.MISTAKE_LIMIT_REACHED,
|
||||
properties: {
|
||||
...args,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when focus chain is enabled/disabled by the user
|
||||
* @param enabled Whether focus chain was enabled (true) or disabled (false)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiFormat } from "@shared/proto/cline/models"
|
||||
import * as assert from "assert"
|
||||
import type { ITelemetryProvider, TelemetryProperties, TelemetrySettings } from "../providers/ITelemetryProvider"
|
||||
import { ROLLOUT_BUNDLE_ACTIVATED_EVENT, ROLLOUT_ERROR_MESSAGE_LIMIT } from "../rollout-metadata"
|
||||
import { TelemetryMetadata, TelemetryService } from "../TelemetryService"
|
||||
|
||||
class FakeProvider implements ITelemetryProvider {
|
||||
@@ -79,6 +80,61 @@ function createTelemetryService(provider: FakeProvider, overrides: Partial<Telem
|
||||
}
|
||||
|
||||
describe("TelemetryService metrics", () => {
|
||||
it("includes rollout metadata on legacy events and metrics", () => {
|
||||
const provider = new FakeProvider()
|
||||
const service = createTelemetryService(provider, {
|
||||
extension_variant: "legacy",
|
||||
})
|
||||
|
||||
service.captureTaskCreated("task-rollout", "anthropic")
|
||||
service.captureTokenUsage("task-rollout", 120, 80, "anthropic", "model-a")
|
||||
|
||||
const taskEvent = provider.logs.find((entry) => entry.event === "task.created")
|
||||
assert.strictEqual(taskEvent?.properties?.extension_variant, "legacy")
|
||||
for (const entry of [...provider.counters, ...provider.histograms]) {
|
||||
assert.strictEqual(entry.attributes.extension_variant, "legacy")
|
||||
}
|
||||
})
|
||||
|
||||
it("captures one bounded rollout fallback event for rollout builds", () => {
|
||||
const provider = new FakeProvider()
|
||||
const service = createTelemetryService(provider, {
|
||||
extension_variant: "legacy",
|
||||
})
|
||||
|
||||
service.captureRolloutBundleActivated({
|
||||
attemptedBundle: "next",
|
||||
actualBundle: "legacy",
|
||||
fallback: true,
|
||||
error: new TypeError("x".repeat(ROLLOUT_ERROR_MESSAGE_LIMIT + 20)),
|
||||
})
|
||||
|
||||
const events = provider.logs.filter((entry) => entry.event === ROLLOUT_BUNDLE_ACTIVATED_EVENT)
|
||||
assert.strictEqual(events.length, 1)
|
||||
assert.strictEqual(events[0].properties?.attempted_bundle, "next")
|
||||
assert.strictEqual(events[0].properties?.actual_bundle, "legacy")
|
||||
assert.strictEqual(events[0].properties?.fallback, true)
|
||||
assert.strictEqual(events[0].properties?.error_type, "TypeError")
|
||||
assert.strictEqual((events[0].properties?.error_message as string).length, ROLLOUT_ERROR_MESSAGE_LIMIT)
|
||||
assert.strictEqual(events[0].properties?.extension_variant, "legacy")
|
||||
})
|
||||
|
||||
it("does not capture rollout activation events for ordinary builds", () => {
|
||||
const provider = new FakeProvider()
|
||||
const service = createTelemetryService(provider)
|
||||
|
||||
service.captureRolloutBundleActivated({
|
||||
attemptedBundle: "legacy",
|
||||
actualBundle: "legacy",
|
||||
fallback: false,
|
||||
})
|
||||
|
||||
assert.strictEqual(
|
||||
provider.logs.some((entry) => entry.event === ROLLOUT_BUNDLE_ACTIVATED_EVENT),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it("captureTokenUsage emits token counters and histograms", () => {
|
||||
const provider = new FakeProvider()
|
||||
const service = createTelemetryService(provider)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as assert from "assert"
|
||||
import { getRolloutErrorProperties, getRolloutTelemetryMetadata, ROLLOUT_ERROR_MESSAGE_LIMIT } from "../rollout-metadata"
|
||||
|
||||
const originalVariant = process.env.CLINE_ROLLOUT_VARIANT
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv("CLINE_ROLLOUT_VARIANT", originalVariant)
|
||||
})
|
||||
|
||||
describe("rollout telemetry metadata", () => {
|
||||
it("returns metadata for a rollout build", () => {
|
||||
process.env.CLINE_ROLLOUT_VARIANT = "legacy"
|
||||
|
||||
assert.deepStrictEqual(getRolloutTelemetryMetadata(), {
|
||||
extension_variant: "legacy",
|
||||
})
|
||||
})
|
||||
|
||||
it("omits metadata for ordinary or invalid builds", () => {
|
||||
delete process.env.CLINE_ROLLOUT_VARIANT
|
||||
assert.deepStrictEqual(getRolloutTelemetryMetadata(), {})
|
||||
|
||||
process.env.CLINE_ROLLOUT_VARIANT = "invalid"
|
||||
assert.deepStrictEqual(getRolloutTelemetryMetadata(), {})
|
||||
})
|
||||
|
||||
it("bounds fallback errors without including stacks", () => {
|
||||
const error = new TypeError("x".repeat(ROLLOUT_ERROR_MESSAGE_LIMIT + 20))
|
||||
const properties = getRolloutErrorProperties(error)
|
||||
|
||||
assert.strictEqual(properties.error_type, "TypeError")
|
||||
assert.strictEqual(properties.error_message.length, ROLLOUT_ERROR_MESSAGE_LIMIT)
|
||||
assert.ok(!properties.error_message.includes("TypeError:"))
|
||||
})
|
||||
})
|
||||
|
||||
function restoreEnv(key: "CLINE_ROLLOUT_VARIANT", value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export type ExtensionVariant = "legacy" | "next"
|
||||
|
||||
export interface RolloutTelemetryMetadata {
|
||||
extension_variant: ExtensionVariant
|
||||
}
|
||||
|
||||
export interface RolloutBundleActivation {
|
||||
attemptedBundle: ExtensionVariant
|
||||
actualBundle: ExtensionVariant
|
||||
fallback: boolean
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
export const ROLLOUT_BUNDLE_ACTIVATED_EVENT = "extension.rollout.bundle_activated"
|
||||
export const ROLLOUT_ERROR_MESSAGE_LIMIT = 500
|
||||
|
||||
/** Return rollout metadata only for bundles built by the combined rollout workflow. */
|
||||
export function getRolloutTelemetryMetadata(): Partial<RolloutTelemetryMetadata> {
|
||||
const variant = process.env.CLINE_ROLLOUT_VARIANT
|
||||
|
||||
if (variant !== "legacy" && variant !== "next") {
|
||||
return {}
|
||||
}
|
||||
|
||||
return { extension_variant: variant }
|
||||
}
|
||||
|
||||
export function getRolloutErrorProperties(error: unknown): {
|
||||
error_type: string
|
||||
error_message: string
|
||||
} {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
error_type: error.name || "Error",
|
||||
error_message: error.message.slice(0, ROLLOUT_ERROR_MESSAGE_LIMIT),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
error_type: error === undefined ? "unknown" : error === null ? "null" : typeof error,
|
||||
error_message: (error === undefined ? "Unknown activation error" : String(error)).slice(0, ROLLOUT_ERROR_MESSAGE_LIMIT),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { expect } from "chai"
|
||||
import {
|
||||
buildModelInfoNameMap,
|
||||
clinePassModelInfoSaneDefaults,
|
||||
internationalZAiModels,
|
||||
mainlandZAiModels,
|
||||
type ModelInfo,
|
||||
resolveClinePassModelInfo,
|
||||
} from "../api"
|
||||
|
||||
describe("ClinePass model info", () => {
|
||||
const createModelInfo = (name: string, contextWindow: number): ModelInfo => ({
|
||||
name,
|
||||
contextWindow,
|
||||
maxTokens: 8_192,
|
||||
supportsPromptCache: false,
|
||||
supportsReasoning: false,
|
||||
thinkingConfig: { maxBudget: 16_384 },
|
||||
})
|
||||
|
||||
it("prefers dynamic model metadata for ClinePass GLM aliases", () => {
|
||||
const modelInfo = resolveClinePassModelInfo(
|
||||
"cline-pass/z-ai/glm-5.2",
|
||||
buildModelInfoNameMap({
|
||||
"z-ai/glm-5.2": createModelInfo("OpenRouter GLM 5.2", 1_000_000),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(modelInfo.name).to.equal("OpenRouter GLM 5.2")
|
||||
expect(modelInfo.contextWindow).to.equal(1_000_000)
|
||||
expect(modelInfo.thinkingConfig).to.deep.equal({ maxBudget: 16_384 })
|
||||
})
|
||||
|
||||
it("falls back to static ClinePass metadata when dynamic metadata is unavailable", () => {
|
||||
const modelInfo = resolveClinePassModelInfo("cline-pass/glm-5.2")
|
||||
|
||||
expect(modelInfo.contextWindow).to.equal(202_752)
|
||||
expect(modelInfo.supportsReasoning).to.equal(true)
|
||||
expect(modelInfo.thinkingConfig).to.equal(undefined)
|
||||
})
|
||||
|
||||
it("preserves dynamic ClinePass model info when no static alias exists", () => {
|
||||
const modelInfo = resolveClinePassModelInfo(
|
||||
"cline-pass/new-model",
|
||||
buildModelInfoNameMap({
|
||||
"zai/new-model": createModelInfo("New model", 1_000_000),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(modelInfo.name).to.equal("New model")
|
||||
expect(modelInfo.supportsReasoning).to.equal(false)
|
||||
expect(modelInfo.thinkingConfig).to.deep.equal({ maxBudget: 16_384 })
|
||||
})
|
||||
|
||||
it("returns stored info for a free (non cline-pass prefixed) model id", () => {
|
||||
const freeModelInfo = createModelInfo("Trinity Large Preview", 512_000)
|
||||
const modelInfo = resolveClinePassModelInfo(
|
||||
"arcee-ai/trinity-large-preview:free",
|
||||
buildModelInfoNameMap({ "arcee-ai/trinity-large-preview:free": freeModelInfo }),
|
||||
)
|
||||
|
||||
expect(modelInfo).to.deep.equal(freeModelInfo)
|
||||
})
|
||||
|
||||
it("falls back to sane defaults for a free model id without dynamic metadata", () => {
|
||||
const modelInfo = resolveClinePassModelInfo("kwaipilot/kat-coder-pro")
|
||||
|
||||
expect(modelInfo).to.deep.equal(clinePassModelInfoSaneDefaults)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Z AI model info", () => {
|
||||
it("includes GLM 5.2 for both direct Z AI entrypoints", () => {
|
||||
for (const models of [internationalZAiModels, mainlandZAiModels]) {
|
||||
expect(models["glm-5.2"].contextWindow).to.equal(1_000_000)
|
||||
expect(models["glm-5.2"].maxTokens).to.equal(128_000)
|
||||
expect(models["glm-5.2"].inputPrice).to.equal(1.4)
|
||||
expect(models["glm-5.2"].outputPrice).to.equal(4.4)
|
||||
expect(models["glm-5.2"].cacheReadsPrice).to.equal(0.26)
|
||||
}
|
||||
})
|
||||
})
|
||||
+367
-46
@@ -129,6 +129,7 @@ export const CLAUDE_SONNET_1M_TIERS = [
|
||||
cacheReadsPrice: 0.6,
|
||||
},
|
||||
]
|
||||
// Claude 4.6+ opus models include the full 1M context window at standard pricing (no long-context premium)
|
||||
export const CLAUDE_OPUS_1M_TIERS = [
|
||||
{
|
||||
contextWindow: 200000,
|
||||
@@ -139,10 +140,10 @@ export const CLAUDE_OPUS_1M_TIERS = [
|
||||
},
|
||||
{
|
||||
contextWindow: Number.MAX_SAFE_INTEGER,
|
||||
inputPrice: 10,
|
||||
outputPrice: 37.5,
|
||||
cacheWritesPrice: 12.5,
|
||||
cacheReadsPrice: 1.0,
|
||||
inputPrice: 5.0,
|
||||
outputPrice: 25,
|
||||
cacheWritesPrice: 6.25,
|
||||
cacheReadsPrice: 0.5,
|
||||
},
|
||||
]
|
||||
export const CLAUDE_FABLE_1M_TIERS = [
|
||||
@@ -179,10 +180,33 @@ export const hicapModelInfoSaneDefaults: HicapCompatibleModelInfo = {
|
||||
// Anthropic
|
||||
// https://docs.anthropic.com/en/docs/about-claude/models // prices updated 2025-01-02
|
||||
export type AnthropicModelId = keyof typeof anthropicModels
|
||||
export const anthropicDefaultModelId: AnthropicModelId = "claude-sonnet-4-5-20250929"
|
||||
export const anthropicDefaultModelId: AnthropicModelId = "claude-sonnet-5"
|
||||
export const ANTHROPIC_MIN_THINKING_BUDGET = 1_024
|
||||
export const ANTHROPIC_MAX_THINKING_BUDGET = 6_000
|
||||
export const anthropicModels = {
|
||||
"claude-sonnet-5": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 2.0,
|
||||
outputPrice: 10.0,
|
||||
cacheWritesPrice: 2.5,
|
||||
cacheReadsPrice: 0.2,
|
||||
},
|
||||
"claude-sonnet-5:1m": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 2.0,
|
||||
outputPrice: 10.0,
|
||||
cacheWritesPrice: 2.5,
|
||||
cacheReadsPrice: 0.2,
|
||||
tiers: CLAUDE_SONNET_1M_TIERS,
|
||||
},
|
||||
"claude-sonnet-4-6": {
|
||||
maxTokens: 64_000,
|
||||
contextWindow: 200_000,
|
||||
@@ -312,6 +336,29 @@ export const anthropicModels = {
|
||||
description:
|
||||
"Anthropic fast mode preview for Claude Opus 4.6 with the 1M context beta enabled. Same model and capabilities with higher output token speed at premium pricing across the full 1M context window. Requires both fast mode and 1M context access on your Anthropic account.",
|
||||
},
|
||||
"claude-opus-5": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 5.0,
|
||||
outputPrice: 25.0,
|
||||
cacheWritesPrice: 6.25,
|
||||
cacheReadsPrice: 0.5,
|
||||
},
|
||||
"claude-opus-5:1m": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 5.0,
|
||||
outputPrice: 25.0,
|
||||
cacheWritesPrice: 6.25,
|
||||
cacheReadsPrice: 0.5,
|
||||
tiers: CLAUDE_OPUS_1M_TIERS,
|
||||
},
|
||||
"claude-opus-4-8": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 200_000,
|
||||
@@ -471,26 +518,26 @@ export const anthropicModels = {
|
||||
|
||||
// Claude Code
|
||||
export type ClaudeCodeModelId = keyof typeof claudeCodeModels
|
||||
export const claudeCodeDefaultModelId: ClaudeCodeModelId = "claude-sonnet-4-5-20250929"
|
||||
export const claudeCodeDefaultModelId: ClaudeCodeModelId = "claude-sonnet-5"
|
||||
export const claudeCodeModels = {
|
||||
sonnet: {
|
||||
...anthropicModels["claude-sonnet-4-5-20250929"],
|
||||
...anthropicModels["claude-sonnet-5"],
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
"sonnet[1m]": {
|
||||
...anthropicModels["claude-sonnet-4-5-20250929:1m"],
|
||||
...anthropicModels["claude-sonnet-5:1m"],
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
opus: {
|
||||
...anthropicModels["claude-opus-4-8"],
|
||||
...anthropicModels["claude-opus-5"],
|
||||
contextWindow: 200_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
"opus[1m]": {
|
||||
...anthropicModels["claude-opus-4-8:1m"],
|
||||
...anthropicModels["claude-opus-5:1m"],
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
@@ -499,6 +546,16 @@ export const claudeCodeModels = {
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
"claude-sonnet-5": {
|
||||
...anthropicModels["claude-sonnet-5"],
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
"claude-sonnet-5[1m]": {
|
||||
...anthropicModels["claude-sonnet-5:1m"],
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
"claude-sonnet-4-6": {
|
||||
...anthropicModels["claude-sonnet-4-6"],
|
||||
supportsImages: false,
|
||||
@@ -534,6 +591,17 @@ export const claudeCodeModels = {
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
"claude-opus-5": {
|
||||
...anthropicModels["claude-opus-5"],
|
||||
contextWindow: 200_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
"claude-opus-5[1m]": {
|
||||
...anthropicModels["claude-opus-5:1m"],
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
"claude-opus-4-8": {
|
||||
...anthropicModels["claude-opus-4-8"],
|
||||
contextWindow: 200_000,
|
||||
@@ -597,8 +665,33 @@ export const claudeCodeModels = {
|
||||
// AWS Bedrock
|
||||
// https://docs.aws.amazon.com/bedrock/latest/userguide/conversation-inference.html
|
||||
export type BedrockModelId = keyof typeof bedrockModels
|
||||
export const bedrockDefaultModelId: BedrockModelId = "anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
export const bedrockDefaultModelId: BedrockModelId = "anthropic.claude-sonnet-5"
|
||||
export const bedrockModels = {
|
||||
"anthropic.claude-sonnet-5": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
inputPrice: 2.0,
|
||||
outputPrice: 10.0,
|
||||
cacheWritesPrice: 2.5,
|
||||
cacheReadsPrice: 0.2,
|
||||
},
|
||||
"anthropic.claude-sonnet-5:1m": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
inputPrice: 2.0,
|
||||
outputPrice: 10.0,
|
||||
cacheWritesPrice: 2.5,
|
||||
cacheReadsPrice: 0.2,
|
||||
tiers: CLAUDE_SONNET_1M_TIERS,
|
||||
},
|
||||
"anthropic.claude-sonnet-4-6": {
|
||||
maxTokens: 64_000,
|
||||
contextWindow: 200_000,
|
||||
@@ -711,6 +804,31 @@ export const bedrockModels = {
|
||||
cacheReadsPrice: 0.5,
|
||||
tiers: CLAUDE_OPUS_1M_TIERS,
|
||||
},
|
||||
"anthropic.claude-opus-5": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
inputPrice: 5.0,
|
||||
outputPrice: 25.0,
|
||||
cacheWritesPrice: 6.25,
|
||||
cacheReadsPrice: 0.5,
|
||||
},
|
||||
"anthropic.claude-opus-5:1m": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
inputPrice: 5.0,
|
||||
outputPrice: 25.0,
|
||||
cacheWritesPrice: 6.25,
|
||||
cacheReadsPrice: 0.5,
|
||||
tiers: CLAUDE_OPUS_1M_TIERS,
|
||||
},
|
||||
"anthropic.claude-opus-4-8": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 200_000,
|
||||
@@ -991,25 +1109,27 @@ export const bedrockModels = {
|
||||
|
||||
// OpenRouter
|
||||
// https://openrouter.ai/models?order=newest&supported_parameters=tools
|
||||
export const openRouterDefaultModelId = "anthropic/claude-sonnet-4.5" // will always exist in openRouterModels
|
||||
export const openRouterDefaultModelId = "anthropic/claude-sonnet-5" // will always exist in openRouterModels
|
||||
export const openRouterClaudeSonnet41mModelId = `anthropic/claude-sonnet-4${CLAUDE_SONNET_1M_SUFFIX}`
|
||||
export const openRouterClaudeSonnet451mModelId = `anthropic/claude-sonnet-4.5${CLAUDE_SONNET_1M_SUFFIX}`
|
||||
export const openRouterClaudeSonnet461mModelId = `anthropic/claude-sonnet-4.6${CLAUDE_SONNET_1M_SUFFIX}`
|
||||
export const openRouterClaudeSonnet51mModelId = `anthropic/claude-sonnet-5${CLAUDE_SONNET_1M_SUFFIX}`
|
||||
export const openRouterClaudeOpus461mModelId = `anthropic/claude-opus-4.6${CLAUDE_SONNET_1M_SUFFIX}`
|
||||
export const openRouterClaudeOpus471mModelId = `anthropic/claude-opus-4.7${CLAUDE_SONNET_1M_SUFFIX}`
|
||||
export const openRouterClaudeOpus481mModelId = `anthropic/claude-opus-4.8${CLAUDE_SONNET_1M_SUFFIX}`
|
||||
export const openRouterClaudeOpus51mModelId = `anthropic/claude-opus-5${CLAUDE_SONNET_1M_SUFFIX}`
|
||||
export const openRouterClaudeFable51mModelId = `anthropic/claude-fable-5${CLAUDE_SONNET_1M_SUFFIX}`
|
||||
export const openRouterDefaultModelInfo: ModelInfo = {
|
||||
maxTokens: 64_000,
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
cacheWritesPrice: 3.75,
|
||||
cacheReadsPrice: 0.3,
|
||||
inputPrice: 2.0,
|
||||
outputPrice: 10.0,
|
||||
cacheWritesPrice: 2.5,
|
||||
cacheReadsPrice: 0.2,
|
||||
description:
|
||||
"Claude Sonnet 4.5 delivers superior intelligence across coding, agentic search, and AI agent capabilities. It's a powerful choice for agentic coding, and can complete tasks across the entire software development lifecycle, from initial planning to bug fixes, maintenance to large refactors. It offers strong performance in both planning and solving for complex coding tasks, making it an ideal choice to power end-to-end software development processes.\n\nRead more in the [blog post here](https://www.anthropic.com/claude/sonnet)",
|
||||
"Claude Sonnet 5 is Anthropic's latest Sonnet model for coding, agents, and professional work. It supports adaptive thinking, prompt caching, image inputs, and long-context workflows.",
|
||||
}
|
||||
|
||||
// Cline custom model - Devstral
|
||||
@@ -1025,7 +1145,7 @@ export const clineDevstralModelInfo: ModelInfo = {
|
||||
}
|
||||
|
||||
export type ClinePassModelId = keyof typeof clinePassModels
|
||||
export const clinePassDefaultModelId = "cline-pass/glm-5.1"
|
||||
export const clinePassDefaultModelId = "cline-pass/glm-5.2"
|
||||
export const clinePassModelInfoSaneDefaults: ModelInfo = {
|
||||
maxTokens: 8_192,
|
||||
contextWindow: 128_000,
|
||||
@@ -1039,8 +1159,8 @@ export const clinePassModelInfoSaneDefaults: ModelInfo = {
|
||||
description: "",
|
||||
}
|
||||
export const clinePassModels = {
|
||||
"cline-pass/glm-5.1": {
|
||||
name: "cline-pass/glm-5.1",
|
||||
"cline-pass/glm-5.2": {
|
||||
name: "cline-pass/glm-5.2",
|
||||
maxTokens: 131_072,
|
||||
contextWindow: 202_752,
|
||||
supportsImages: false,
|
||||
@@ -1069,9 +1189,12 @@ export function buildModelInfoNameMap(models: Record<string, ModelInfo>): Record
|
||||
}
|
||||
|
||||
export function resolveClinePassModelInfo(modelId: string, modelInfoByName?: Record<string, ModelInfo>): ModelInfo {
|
||||
const modelSlug = getModelSlug(modelId)
|
||||
const clinePassSlugModelId = `cline-pass/${modelSlug}`
|
||||
return (
|
||||
modelInfoByName?.[modelSlug] ??
|
||||
clinePassModels[modelId as keyof typeof clinePassModels] ??
|
||||
modelInfoByName?.[getModelSlug(modelId)] ??
|
||||
clinePassModels[clinePassSlugModelId as keyof typeof clinePassModels] ??
|
||||
clinePassModelInfoSaneDefaults
|
||||
)
|
||||
}
|
||||
@@ -1230,19 +1353,46 @@ export const vertexModels = {
|
||||
supportsThinkingLevel: true,
|
||||
},
|
||||
},
|
||||
"claude-sonnet-4-6": {
|
||||
maxTokens: 64_000,
|
||||
contextWindow: 200_000,
|
||||
"claude-sonnet-5": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
inputPrice: 2.0,
|
||||
outputPrice: 10.0,
|
||||
cacheWritesPrice: 2.5,
|
||||
cacheReadsPrice: 0.2,
|
||||
supportsReasoning: true,
|
||||
tiers: CLAUDE_SONNET_1M_TIERS,
|
||||
},
|
||||
"claude-sonnet-5:1m": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 2.0,
|
||||
outputPrice: 10.0,
|
||||
cacheWritesPrice: 2.5,
|
||||
cacheReadsPrice: 0.2,
|
||||
supportsReasoning: true,
|
||||
tiers: CLAUDE_SONNET_1M_TIERS,
|
||||
},
|
||||
"claude-sonnet-4-6": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
cacheWritesPrice: 3.75,
|
||||
cacheReadsPrice: 0.3,
|
||||
supportsReasoning: true,
|
||||
tiers: CLAUDE_SONNET_1M_TIERS,
|
||||
},
|
||||
"claude-sonnet-4-6:1m": {
|
||||
maxTokens: 64_000,
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
@@ -1264,6 +1414,19 @@ export const vertexModels = {
|
||||
cacheReadsPrice: 0.3,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
"claude-sonnet-4-5": {
|
||||
maxTokens: 64_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
cacheWritesPrice: 3.75,
|
||||
cacheReadsPrice: 0.3,
|
||||
supportsReasoning: true,
|
||||
tiers: CLAUDE_SONNET_1M_TIERS,
|
||||
},
|
||||
"claude-sonnet-4@20250514": {
|
||||
maxTokens: 64_000,
|
||||
contextWindow: 200_000,
|
||||
@@ -1286,9 +1449,21 @@ export const vertexModels = {
|
||||
cacheReadsPrice: 0.1,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
"claude-haiku-4-5": {
|
||||
maxTokens: 64_000,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
inputPrice: 1.0,
|
||||
outputPrice: 5.0,
|
||||
cacheWritesPrice: 1.25,
|
||||
cacheReadsPrice: 0.1,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
"claude-opus-4-6": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 200_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
@@ -1297,6 +1472,7 @@ export const vertexModels = {
|
||||
cacheWritesPrice: 6.25,
|
||||
cacheReadsPrice: 0.5,
|
||||
supportsReasoning: true,
|
||||
tiers: CLAUDE_OPUS_1M_TIERS,
|
||||
},
|
||||
"claude-opus-4-6:1m": {
|
||||
maxTokens: 128_000,
|
||||
@@ -1311,9 +1487,9 @@ export const vertexModels = {
|
||||
supportsReasoning: true,
|
||||
tiers: CLAUDE_OPUS_1M_TIERS,
|
||||
},
|
||||
"claude-opus-4-8": {
|
||||
"claude-opus-5": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 200_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
@@ -1322,6 +1498,33 @@ export const vertexModels = {
|
||||
cacheWritesPrice: 6.25,
|
||||
cacheReadsPrice: 0.5,
|
||||
supportsReasoning: true,
|
||||
tiers: CLAUDE_OPUS_1M_TIERS,
|
||||
},
|
||||
"claude-opus-5:1m": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
inputPrice: 5.0,
|
||||
outputPrice: 25.0,
|
||||
cacheWritesPrice: 6.25,
|
||||
cacheReadsPrice: 0.5,
|
||||
supportsReasoning: true,
|
||||
tiers: CLAUDE_OPUS_1M_TIERS,
|
||||
},
|
||||
"claude-opus-4-8": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
inputPrice: 5.0,
|
||||
outputPrice: 25.0,
|
||||
cacheWritesPrice: 6.25,
|
||||
cacheReadsPrice: 0.5,
|
||||
supportsReasoning: true,
|
||||
tiers: CLAUDE_OPUS_1M_TIERS,
|
||||
},
|
||||
"claude-opus-4-8:1m": {
|
||||
maxTokens: 128_000,
|
||||
@@ -1338,7 +1541,7 @@ export const vertexModels = {
|
||||
},
|
||||
"claude-fable-5": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 200_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
@@ -1347,6 +1550,7 @@ export const vertexModels = {
|
||||
cacheWritesPrice: 12.5,
|
||||
cacheReadsPrice: 1,
|
||||
supportsReasoning: true,
|
||||
tiers: CLAUDE_FABLE_1M_TIERS,
|
||||
},
|
||||
"claude-fable-5:1m": {
|
||||
maxTokens: 128_000,
|
||||
@@ -1363,7 +1567,7 @@ export const vertexModels = {
|
||||
},
|
||||
"claude-opus-4-7": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 200_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
@@ -1372,6 +1576,7 @@ export const vertexModels = {
|
||||
cacheWritesPrice: 6.25,
|
||||
cacheReadsPrice: 0.5,
|
||||
supportsReasoning: true,
|
||||
tiers: CLAUDE_OPUS_1M_TIERS,
|
||||
},
|
||||
"claude-opus-4-7:1m": {
|
||||
maxTokens: 128_000,
|
||||
@@ -1397,6 +1602,18 @@ export const vertexModels = {
|
||||
cacheReadsPrice: 0.5,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
"claude-opus-4-5": {
|
||||
maxTokens: 64_000,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
inputPrice: 5.0,
|
||||
outputPrice: 25.0,
|
||||
cacheWritesPrice: 6.25,
|
||||
cacheReadsPrice: 0.5,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
"claude-opus-4-1@20250805": {
|
||||
maxTokens: 32_000,
|
||||
contextWindow: 200_000,
|
||||
@@ -1408,6 +1625,18 @@ export const vertexModels = {
|
||||
cacheReadsPrice: 1.5,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
"claude-opus-4-1": {
|
||||
maxTokens: 32_000,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
inputPrice: 15.0,
|
||||
outputPrice: 75.0,
|
||||
cacheWritesPrice: 18.75,
|
||||
cacheReadsPrice: 1.5,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
"claude-opus-4@20250514": {
|
||||
maxTokens: 32_000,
|
||||
contextWindow: 200_000,
|
||||
@@ -1705,6 +1934,27 @@ export const vertexGlobalModels: Record<string, ModelInfo> = Object.fromEntries(
|
||||
Object.entries(vertexModels).filter(([_k, v]) => Object.hasOwn(v, "supportsGlobalEndpoint")),
|
||||
) as Record<string, ModelInfo>
|
||||
|
||||
// Defaults for custom (free-form) Vertex model entries. Context window, max output
|
||||
// tokens, image support, and reasoning support are user-editable in settings.
|
||||
export const vertexCustomModelInfoSaneDefaults: ModelInfo = {
|
||||
maxTokens: 64_000,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsReasoning: true,
|
||||
supportsPromptCache: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
}
|
||||
|
||||
export function getVertexCustomModelInfo(customModelInfo?: ModelInfo): ModelInfo {
|
||||
return {
|
||||
...vertexCustomModelInfoSaneDefaults,
|
||||
...customModelInfo,
|
||||
// Fixed for all custom Vertex models regardless of user edits.
|
||||
supportsPromptCache: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
}
|
||||
}
|
||||
|
||||
export const openAiModelInfoSaneDefaults: OpenAiCompatibleModelInfo = {
|
||||
maxTokens: -1,
|
||||
contextWindow: 128_000,
|
||||
@@ -2369,6 +2619,42 @@ export const openAiNativeModels = {
|
||||
export type OpenAiCodexModelId = keyof typeof openAiCodexModels
|
||||
export const openAiCodexDefaultModelId: OpenAiCodexModelId = "gpt-5.3-codex"
|
||||
export const openAiCodexModels = {
|
||||
"gpt-5.6-sol": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 372_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
apiFormat: ApiFormat.OPENAI_RESPONSES,
|
||||
// Subscription-based: no per-token costs
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "GPT-5.6 Sol: OpenAI's latest frontier agentic coding model via ChatGPT subscription",
|
||||
},
|
||||
"gpt-5.6-terra": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 372_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
apiFormat: ApiFormat.OPENAI_RESPONSES,
|
||||
// Subscription-based: no per-token costs
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "GPT-5.6 Terra: OpenAI's balanced agentic coding model via ChatGPT subscription",
|
||||
},
|
||||
"gpt-5.6-luna": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 372_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
apiFormat: ApiFormat.OPENAI_RESPONSES,
|
||||
// Subscription-based: no per-token costs
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "GPT-5.6 Luna: OpenAI's fast agentic coding model via ChatGPT subscription",
|
||||
},
|
||||
"gpt-5.5": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 1_000_000,
|
||||
@@ -4360,18 +4646,18 @@ export const groqModels = {
|
||||
|
||||
// Requesty
|
||||
// https://requesty.ai/models
|
||||
export const requestyDefaultModelId = "anthropic/claude-3-7-sonnet-latest"
|
||||
export const requestyDefaultModelId = "anthropic/claude-sonnet-5"
|
||||
export const requestyDefaultModelInfo: ModelInfo = {
|
||||
maxTokens: 8192,
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
cacheWritesPrice: 3.75,
|
||||
cacheReadsPrice: 0.3,
|
||||
description: "Anthropic's most intelligent model. Highest level of intelligence and capability.",
|
||||
inputPrice: 2.0,
|
||||
outputPrice: 10.0,
|
||||
cacheWritesPrice: 2.5,
|
||||
cacheReadsPrice: 0.2,
|
||||
description: "Anthropic's latest Sonnet model for coding, agents, and professional work.",
|
||||
}
|
||||
|
||||
// SAP AI Core
|
||||
@@ -4380,6 +4666,13 @@ export const sapAiCoreDefaultModelId: SapAiCoreModelId = "anthropic--claude-3.5-
|
||||
// Pricing is calculated using Capacity Units, not directly in USD
|
||||
const sapAiCoreModelDescription = "Pricing is calculated using SAP's Capacity Units rather than direct USD pricing."
|
||||
export const sapAiCoreModels = {
|
||||
"anthropic--claude-sonnet-5": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
description: sapAiCoreModelDescription,
|
||||
},
|
||||
"anthropic--claude-4.5-haiku": {
|
||||
maxTokens: 64000,
|
||||
contextWindow: 200_000,
|
||||
@@ -4695,6 +4988,16 @@ export const sapAiCoreModels = {
|
||||
// Moonshot AI Studio
|
||||
// https://platform.moonshot.ai/docs/pricing/chat
|
||||
export const moonshotModels = {
|
||||
"kimi-k3": {
|
||||
maxTokens: 131_072,
|
||||
contextWindow: 1_048_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
cacheReadsPrice: 0.3,
|
||||
},
|
||||
"kimi-k2.6": {
|
||||
maxTokens: 32_000,
|
||||
contextWindow: 262_144,
|
||||
@@ -4983,12 +5286,22 @@ export type BasetenModelId = keyof typeof basetenModels
|
||||
export const basetenDefaultModelId = "zai-org/GLM-4.6" satisfies BasetenModelId
|
||||
|
||||
// Z AI
|
||||
// https://docs.z.ai/guides/llm/glm-5.2
|
||||
// https://docs.z.ai/guides/llm/glm-5.1
|
||||
// https://docs.z.ai/guides/llm/glm-5
|
||||
// https://docs.z.ai/guides/overview/pricing
|
||||
export type internationalZAiModelId = keyof typeof internationalZAiModels
|
||||
export const internationalZAiDefaultModelId: internationalZAiModelId = "glm-5.1"
|
||||
export const internationalZAiModels = {
|
||||
"glm-5.2": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
cacheReadsPrice: 0.26,
|
||||
inputPrice: 1.4,
|
||||
outputPrice: 4.4,
|
||||
},
|
||||
"glm-5.1": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 200_000,
|
||||
@@ -5054,6 +5367,15 @@ export const internationalZAiModels = {
|
||||
export type mainlandZAiModelId = keyof typeof mainlandZAiModels
|
||||
export const mainlandZAiDefaultModelId: mainlandZAiModelId = "glm-5.1"
|
||||
export const mainlandZAiModels = {
|
||||
"glm-5.2": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
cacheReadsPrice: 0.26,
|
||||
inputPrice: 1.4,
|
||||
outputPrice: 4.4,
|
||||
},
|
||||
"glm-5.1": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 200_000,
|
||||
@@ -5193,8 +5515,7 @@ export const fireworksModels = {
|
||||
outputPrice: 8,
|
||||
cacheWritesPrice: 0,
|
||||
cacheReadsPrice: 0.3,
|
||||
description:
|
||||
"Kimi K2.6 Turbo router for high-performance agentic workloads with vision and text reasoning.",
|
||||
description: "Kimi K2.6 Turbo router for high-performance agentic workloads with vision and text reasoning.",
|
||||
},
|
||||
"accounts/fireworks/routers/kimi-k2p6-fast": {
|
||||
maxTokens: 262000,
|
||||
@@ -5205,8 +5526,7 @@ export const fireworksModels = {
|
||||
outputPrice: 8,
|
||||
cacheWritesPrice: 0,
|
||||
cacheReadsPrice: 0.3,
|
||||
description:
|
||||
"Kimi K2.6 Fast router for high-performance agentic workloads with vision and text reasoning.",
|
||||
description: "Kimi K2.6 Fast router for high-performance agentic workloads with vision and text reasoning.",
|
||||
},
|
||||
"accounts/fireworks/routers/kimi-k2p7-code-fast": {
|
||||
maxTokens: 262000,
|
||||
@@ -5217,8 +5537,7 @@ export const fireworksModels = {
|
||||
outputPrice: 8,
|
||||
cacheWritesPrice: 0,
|
||||
cacheReadsPrice: 0.38,
|
||||
description:
|
||||
"Kimi K2.7 Code Fast router for high-performance coding workloads with vision and text reasoning.",
|
||||
description: "Kimi K2.7 Code Fast router for high-performance coding workloads with vision and text reasoning.",
|
||||
},
|
||||
"accounts/fireworks/models/deepseek-v4-flash": {
|
||||
maxTokens: 384000,
|
||||
@@ -5253,7 +5572,8 @@ export const fireworksModels = {
|
||||
outputPrice: 4.4,
|
||||
cacheWritesPrice: 0,
|
||||
cacheReadsPrice: 0.26,
|
||||
description: "GLM 5.2 is a next-generation general-purpose model optimized for coding, reasoning, and agentic workflows with a 1M context window.",
|
||||
description:
|
||||
"GLM 5.2 is a next-generation general-purpose model optimized for coding, reasoning, and agentic workflows with a 1M context window.",
|
||||
},
|
||||
"accounts/fireworks/models/glm-5p1": {
|
||||
maxTokens: 131072,
|
||||
@@ -5297,7 +5617,8 @@ export const fireworksModels = {
|
||||
outputPrice: 1.2,
|
||||
cacheWritesPrice: 0,
|
||||
cacheReadsPrice: 0.06,
|
||||
description: "MiniMax M2.7 is tuned for strong real-world performance across coding, agent-driven, and workflow-heavy tasks.",
|
||||
description:
|
||||
"MiniMax M2.7 is tuned for strong real-world performance across coding, agent-driven, and workflow-heavy tasks.",
|
||||
},
|
||||
"accounts/fireworks/models/qwen3p7-plus": {
|
||||
maxTokens: 262144,
|
||||
|
||||
@@ -55,8 +55,8 @@ export const CLINE_ONBOARDING_MODELS: OnboardingModel[] = [
|
||||
},
|
||||
{
|
||||
group: "frontier",
|
||||
id: "anthropic/claude-sonnet-4.5",
|
||||
name: "Anthropic: Claude Sonnet 4.5",
|
||||
id: "anthropic/claude-sonnet-5",
|
||||
name: "Anthropic: Claude Sonnet 5",
|
||||
badge: "Best",
|
||||
score: 97,
|
||||
latency: 3,
|
||||
@@ -64,8 +64,8 @@ export const CLINE_ONBOARDING_MODELS: OnboardingModel[] = [
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
inputPrice: 2.0,
|
||||
outputPrice: 10.0,
|
||||
tiers: [],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -22,8 +22,8 @@ export const CLINE_RECOMMENDED_MODELS_FALLBACK: ClineRecommendedModelsData = {
|
||||
tags: ["NEW"],
|
||||
},
|
||||
{
|
||||
id: "anthropic/claude-sonnet-4.6",
|
||||
name: "Anthropic Claude Sonnet 4.6",
|
||||
id: "anthropic/claude-sonnet-5",
|
||||
name: "Anthropic Claude Sonnet 5",
|
||||
description: "Latest Sonnet release with strong coding and agent performance",
|
||||
tags: ["NEW"],
|
||||
},
|
||||
|
||||
@@ -55,6 +55,7 @@ function convertModelInfoToProtoOpenRouter(info: ModelInfo | undefined): OpenRou
|
||||
contextWindow: info.contextWindow,
|
||||
supportsImages: info.supportsImages,
|
||||
supportsPromptCache: info.supportsPromptCache ?? false,
|
||||
supportsReasoning: info.supportsReasoning,
|
||||
inputPrice: info.inputPrice,
|
||||
outputPrice: info.outputPrice,
|
||||
cacheWritesPrice: info.cacheWritesPrice,
|
||||
@@ -77,6 +78,7 @@ function convertProtoToModelInfo(info: OpenRouterModelInfo | undefined): ModelIn
|
||||
contextWindow: info.contextWindow,
|
||||
supportsImages: info.supportsImages,
|
||||
supportsPromptCache: info.supportsPromptCache,
|
||||
supportsReasoning: info.supportsReasoning,
|
||||
inputPrice: info.inputPrice,
|
||||
outputPrice: info.outputPrice,
|
||||
cacheWritesPrice: info.cacheWritesPrice,
|
||||
@@ -528,6 +530,8 @@ export function convertApiConfigurationToProto(config: ApiConfiguration): ProtoA
|
||||
planModeVsCodeLmModelSelector: config.planModeVsCodeLmModelSelector,
|
||||
planModeAwsBedrockCustomSelected: config.planModeAwsBedrockCustomSelected,
|
||||
planModeAwsBedrockCustomModelBaseId: config.planModeAwsBedrockCustomModelBaseId as string | undefined,
|
||||
planModeVertexCustomModelSelected: config.planModeVertexCustomModelSelected,
|
||||
planModeVertexCustomModelInfo: convertModelInfoToProtoOpenRouter(config.planModeVertexCustomModelInfo),
|
||||
planModeOpenRouterModelId: config.planModeOpenRouterModelId,
|
||||
planModeOpenRouterModelInfo: convertModelInfoToProtoOpenRouter(config.planModeOpenRouterModelInfo),
|
||||
planModeClineModelId: config.planModeClineModelId,
|
||||
@@ -574,6 +578,8 @@ export function convertApiConfigurationToProto(config: ApiConfiguration): ProtoA
|
||||
actModeVsCodeLmModelSelector: config.actModeVsCodeLmModelSelector,
|
||||
actModeAwsBedrockCustomSelected: config.actModeAwsBedrockCustomSelected,
|
||||
actModeAwsBedrockCustomModelBaseId: config.actModeAwsBedrockCustomModelBaseId as string | undefined,
|
||||
actModeVertexCustomModelSelected: config.actModeVertexCustomModelSelected,
|
||||
actModeVertexCustomModelInfo: convertModelInfoToProtoOpenRouter(config.actModeVertexCustomModelInfo),
|
||||
actModeOpenRouterModelId: config.actModeOpenRouterModelId,
|
||||
actModeOpenRouterModelInfo: convertModelInfoToProtoOpenRouter(config.actModeOpenRouterModelInfo),
|
||||
actModeClineModelId: config.actModeClineModelId,
|
||||
@@ -715,6 +721,8 @@ export function convertProtoToApiConfiguration(protoConfig: ProtoApiConfiguratio
|
||||
planModeVsCodeLmModelSelector: protoConfig.planModeVsCodeLmModelSelector,
|
||||
planModeAwsBedrockCustomSelected: protoConfig.planModeAwsBedrockCustomSelected,
|
||||
planModeAwsBedrockCustomModelBaseId: protoConfig.planModeAwsBedrockCustomModelBaseId as BedrockModelId | undefined,
|
||||
planModeVertexCustomModelSelected: protoConfig.planModeVertexCustomModelSelected,
|
||||
planModeVertexCustomModelInfo: convertProtoToModelInfo(protoConfig.planModeVertexCustomModelInfo),
|
||||
planModeOpenRouterModelId: protoConfig.planModeOpenRouterModelId,
|
||||
planModeOpenRouterModelInfo: convertProtoToModelInfo(protoConfig.planModeOpenRouterModelInfo),
|
||||
planModeClineModelId: protoConfig.planModeClineModelId,
|
||||
@@ -762,6 +770,8 @@ export function convertProtoToApiConfiguration(protoConfig: ProtoApiConfiguratio
|
||||
actModeVsCodeLmModelSelector: protoConfig.actModeVsCodeLmModelSelector,
|
||||
actModeAwsBedrockCustomSelected: protoConfig.actModeAwsBedrockCustomSelected,
|
||||
actModeAwsBedrockCustomModelBaseId: protoConfig.actModeAwsBedrockCustomModelBaseId as BedrockModelId | undefined,
|
||||
actModeVertexCustomModelSelected: protoConfig.actModeVertexCustomModelSelected,
|
||||
actModeVertexCustomModelInfo: convertProtoToModelInfo(protoConfig.actModeVertexCustomModelInfo),
|
||||
actModeOpenRouterModelId: protoConfig.actModeOpenRouterModelId,
|
||||
actModeOpenRouterModelInfo: convertProtoToModelInfo(protoConfig.actModeOpenRouterModelInfo),
|
||||
actModeClineModelId: protoConfig.actModeClineModelId,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
},
|
||||
{
|
||||
"value": "cline-pass",
|
||||
"label": "Cline Pass"
|
||||
"label": "ClinePass"
|
||||
},
|
||||
{
|
||||
"value": "openai-codex",
|
||||
|
||||
@@ -15,7 +15,7 @@ export enum FeatureFlag {
|
||||
// Rollout flag for Cline provider model sourcing:
|
||||
// off => OpenRouter model list, on => Cline endpoint model list.
|
||||
EXTENSION_CLINE_MODELS_ENDPOINT = "extension_cline_models_endpoint",
|
||||
// Enables Cline Pass provider/model list exposure.
|
||||
// Enables ClinePass provider/model list exposure.
|
||||
CLINE_PASS = "ext-cline-pass",
|
||||
// Use the websocket mode for OpenAI native Responses API format
|
||||
OPENAI_RESPONSES_WEBSOCKET_MODE = "openai-responses-websocket-mode",
|
||||
|
||||
@@ -23,7 +23,7 @@ describe("Provider key mapping", () => {
|
||||
expect(getProviderModelIdKey("cline", "plan")).to.equal("planModeClineModelId")
|
||||
})
|
||||
|
||||
it("uses separate model keys for Cline Pass", () => {
|
||||
it("uses separate model keys for ClinePass", () => {
|
||||
expect(getProviderModelIdKey("cline-pass", "act")).to.equal("actModeClinePassModelId")
|
||||
expect(getProviderModelIdKey("cline-pass", "plan")).to.equal("planModeClinePassModelId")
|
||||
})
|
||||
|
||||
@@ -152,6 +152,8 @@ const API_HANDLER_SETTINGS_FIELDS = {
|
||||
planModeVsCodeLmModelSelector: { default: undefined as LanguageModelChatSelector | undefined },
|
||||
planModeAwsBedrockCustomSelected: { default: undefined as boolean | undefined },
|
||||
planModeAwsBedrockCustomModelBaseId: { default: undefined as string | undefined },
|
||||
planModeVertexCustomModelSelected: { default: undefined as boolean | undefined },
|
||||
planModeVertexCustomModelInfo: { default: undefined as ModelInfo | undefined },
|
||||
planModeOpenRouterModelId: { default: undefined as string | undefined },
|
||||
planModeOpenRouterModelInfo: { default: undefined as ModelInfo | undefined },
|
||||
planModeClineModelId: { default: undefined as string | undefined },
|
||||
@@ -198,6 +200,8 @@ const API_HANDLER_SETTINGS_FIELDS = {
|
||||
actModeVsCodeLmModelSelector: { default: undefined as LanguageModelChatSelector | undefined },
|
||||
actModeAwsBedrockCustomSelected: { default: undefined as boolean | undefined },
|
||||
actModeAwsBedrockCustomModelBaseId: { default: undefined as string | undefined },
|
||||
actModeVertexCustomModelSelected: { default: undefined as boolean | undefined },
|
||||
actModeVertexCustomModelInfo: { default: undefined as ModelInfo | undefined },
|
||||
actModeOpenRouterModelId: { default: undefined as string | undefined },
|
||||
actModeOpenRouterModelInfo: { default: undefined as ModelInfo | undefined },
|
||||
actModeClineModelId: { default: undefined as string | undefined },
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { normalizeOpenaiReasoningEffort, type OpenaiReasoningEffort } from "../storage/types"
|
||||
|
||||
export interface ClaudeOpusAdaptiveThinkingSettings {
|
||||
export interface ClaudeAdaptiveThinkingSettings {
|
||||
enabled: boolean
|
||||
effort?: OpenaiReasoningEffort
|
||||
}
|
||||
|
||||
export function isClaudeOpusAdaptiveThinkingModel(modelId?: string): boolean {
|
||||
export type ClaudeOpusAdaptiveThinkingSettings = ClaudeAdaptiveThinkingSettings
|
||||
|
||||
export function isClaudeAdaptiveThinkingModel(modelId?: string): boolean {
|
||||
if (!modelId) {
|
||||
return false
|
||||
}
|
||||
@@ -14,14 +16,20 @@ export function isClaudeOpusAdaptiveThinkingModel(modelId?: string): boolean {
|
||||
const adaptiveVersions = ["4-6", "4.6", "4-7", "4.7", "4-8", "4.8"]
|
||||
return (
|
||||
id.includes("claude-fable-5") ||
|
||||
id.includes("claude-sonnet-5") ||
|
||||
id.includes("claude-5-sonnet") ||
|
||||
id.includes("claude-opus-5") ||
|
||||
id.includes("claude-5-opus") ||
|
||||
adaptiveVersions.some((version) => id.includes(`claude-opus-${version}`) || id.includes(`claude-${version}-opus`))
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveClaudeOpusAdaptiveThinking(
|
||||
export const isClaudeOpusAdaptiveThinkingModel = isClaudeAdaptiveThinkingModel
|
||||
|
||||
export function resolveClaudeAdaptiveThinking(
|
||||
reasoningEffort?: string,
|
||||
legacyThinkingBudgetTokens?: number,
|
||||
): ClaudeOpusAdaptiveThinkingSettings {
|
||||
): ClaudeAdaptiveThinkingSettings {
|
||||
if (reasoningEffort) {
|
||||
const effort = normalizeOpenaiReasoningEffort(reasoningEffort)
|
||||
return effort === "none" ? { enabled: false } : { enabled: true, effort }
|
||||
@@ -30,6 +38,8 @@ export function resolveClaudeOpusAdaptiveThinking(
|
||||
return legacyThinkingBudgetTokens && legacyThinkingBudgetTokens > 0 ? { enabled: true, effort: "high" } : { enabled: false }
|
||||
}
|
||||
|
||||
export const resolveClaudeOpusAdaptiveThinking = resolveClaudeAdaptiveThinking
|
||||
|
||||
export function supportsReasoningEffortForModel(modelId?: string): boolean {
|
||||
if (!modelId) {
|
||||
return false
|
||||
@@ -37,11 +47,20 @@ export function supportsReasoningEffortForModel(modelId?: string): boolean {
|
||||
|
||||
const id = modelId.toLowerCase()
|
||||
return (
|
||||
id.includes("deepseek") ||
|
||||
id.includes("gemini") ||
|
||||
id.includes("glm") ||
|
||||
id.includes("gpt") ||
|
||||
id.includes("kimi") ||
|
||||
id.includes("mimo") ||
|
||||
id.includes("minimax") ||
|
||||
id.includes("moonshot") ||
|
||||
id.startsWith("openai/o") ||
|
||||
id.includes("/o") ||
|
||||
id.startsWith("o") ||
|
||||
id.includes("qwen") ||
|
||||
id.includes("z-ai") ||
|
||||
id.includes("zai") ||
|
||||
id.includes("grok")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -94,8 +94,8 @@ export const E2E_MOCK_CLINE_RECOMMENDED_MODELS = {
|
||||
],
|
||||
recommended: [
|
||||
{
|
||||
id: "anthropic/claude-sonnet-4.6",
|
||||
name: "anthropic/claude-sonnet-4.6",
|
||||
id: "anthropic/claude-sonnet-5",
|
||||
name: "anthropic/claude-sonnet-5",
|
||||
description: "Recommended model for e2e onboarding",
|
||||
tags: ["BEST"],
|
||||
},
|
||||
@@ -123,12 +123,12 @@ export const E2E_MOCK_CLINE_MODELS = [
|
||||
supported_parameters: [],
|
||||
},
|
||||
{
|
||||
id: "anthropic/claude-sonnet-4.6",
|
||||
name: "anthropic/claude-sonnet-4.6",
|
||||
id: "anthropic/claude-sonnet-5",
|
||||
name: "anthropic/claude-sonnet-5",
|
||||
description: "Recommended model for e2e onboarding",
|
||||
context_length: 200_000,
|
||||
top_provider: {
|
||||
max_completion_tokens: 64_000,
|
||||
max_completion_tokens: 128_000,
|
||||
context_length: 200_000,
|
||||
is_moderated: false,
|
||||
},
|
||||
@@ -136,10 +136,10 @@ export const E2E_MOCK_CLINE_MODELS = [
|
||||
modality: "text->text",
|
||||
},
|
||||
pricing: {
|
||||
prompt: "0.000003",
|
||||
completion: "0.000015",
|
||||
input_cache_read: "0.0000003",
|
||||
input_cache_write: "0.00000375",
|
||||
prompt: "0.000002",
|
||||
completion: "0.00001",
|
||||
input_cache_read: "0.0000002",
|
||||
input_cache_write: "0.0000025",
|
||||
},
|
||||
supported_parameters: ["include_reasoning"],
|
||||
},
|
||||
|
||||
@@ -20,7 +20,9 @@ describe("Cline Extension", () => {
|
||||
})
|
||||
|
||||
it("should successfully execute the plus button command", async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 400))
|
||||
const packageJSON = JSON.parse(await readFile(packagePath, "utf8"))
|
||||
const id = packageJSON.publisher + "." + packageJSON.name
|
||||
await vscode.extensions.getExtension(id)?.activate()
|
||||
await vscode.commands.executeCommand("cline.plusButtonClicked")
|
||||
})
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import type { ApiHandlerModel, ApiProviderInfo } from "@core/api"
|
||||
import { isClaudeAdaptiveThinkingModel } from "@shared/utils/reasoning-support"
|
||||
import {
|
||||
GEMINI_FLASH_MAX_OUTPUT_TOKENS,
|
||||
isClaude4PlusModelFamily,
|
||||
isGeminiFlashModel,
|
||||
isGLMModelFamily,
|
||||
isGPT51PlusModel,
|
||||
isGPT5ModelFamily,
|
||||
isGptOssModelFamily,
|
||||
isNativeToolCallingConfig,
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
isPoolsideModelFamily,
|
||||
modelDoesntSupportWebp,
|
||||
shouldSkipReasoningForModel,
|
||||
supportsReasoningEffortForModel,
|
||||
} from "../model-utils"
|
||||
|
||||
// Minimal helper — modelDoesntSupportWebp only reads apiHandlerModel.id
|
||||
@@ -37,6 +40,7 @@ describe("shouldSkipReasoningForModel", () => {
|
||||
shouldSkipReasoningForModel("claude-3-sonnet").should.equal(false)
|
||||
shouldSkipReasoningForModel("gpt-4").should.equal(false)
|
||||
shouldSkipReasoningForModel("gemini-pro").should.equal(false)
|
||||
shouldSkipReasoningForModel("zai/glm-5.2").should.equal(false)
|
||||
})
|
||||
|
||||
it("should return false for undefined or empty model IDs", () => {
|
||||
@@ -50,6 +54,31 @@ describe("shouldSkipReasoningForModel", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("supportsReasoningEffortForModel", () => {
|
||||
it("should return true for OpenRouter/Cline model families that use reasoning effort", () => {
|
||||
for (const modelId of [
|
||||
"zai/glm-5.2",
|
||||
"z-ai/glm-5.2",
|
||||
"cline-pass/glm-5.2",
|
||||
"moonshotai/kimi-k2-thinking",
|
||||
"kimi-k2-thinking",
|
||||
"accounts/fireworks/models/kimi-k2p6",
|
||||
"accounts/fireworks/models/minimax-m3",
|
||||
"minimax/MiniMax-M2.7",
|
||||
"provider/mimo-vl",
|
||||
"qwen/qwen3.7-max",
|
||||
"deepseek/deepseek-r1",
|
||||
]) {
|
||||
supportsReasoningEffortForModel(modelId).should.equal(true)
|
||||
}
|
||||
})
|
||||
|
||||
it("should return false for undefined and unrelated model IDs", () => {
|
||||
supportsReasoningEffortForModel(undefined).should.equal(false)
|
||||
supportsReasoningEffortForModel("anthropic/claude-sonnet-4.5").should.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isClaude4PlusModelFamily", () => {
|
||||
it("should return true for Claude 4+ model IDs with version numbers", () => {
|
||||
isClaude4PlusModelFamily("claude-sonnet-4-5-20250929").should.equal(true)
|
||||
@@ -77,6 +106,18 @@ describe("isClaude4PlusModelFamily", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("isClaudeAdaptiveThinkingModel", () => {
|
||||
it("should return true for Claude Sonnet 5 IDs across provider naming variants", () => {
|
||||
for (const modelId of [
|
||||
"claude-sonnet-5",
|
||||
"anthropic/claude-sonnet-5:1m",
|
||||
"anthropic/claude-5-sonnet",
|
||||
]) {
|
||||
isClaudeAdaptiveThinkingModel(modelId).should.equal(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("isGPT5ModelFamily", () => {
|
||||
it("should return true for GPT-5 model IDs with hyphen", () => {
|
||||
isGPT5ModelFamily("gpt-5").should.equal(true)
|
||||
@@ -105,6 +146,14 @@ describe("isGPT5ModelFamily", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("isGPT51PlusModel", () => {
|
||||
it("should recognize GPT-5.6 model ID variants", () => {
|
||||
isGPT51PlusModel("gpt-5.6-sol").should.equal(true)
|
||||
isGPT51PlusModel("gpt-5-6-terra").should.equal(true)
|
||||
isGPT51PlusModel("openai/gpt-5.6-luna").should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isGptOssModelFamily", () => {
|
||||
it("should return true for gpt-oss model IDs", () => {
|
||||
isGptOssModelFamily("gpt-oss-120b").should.equal(true)
|
||||
@@ -142,6 +191,14 @@ describe("isPoolsideModelFamily", () => {
|
||||
isNativeToolCallingConfig(providerInfo("openai-compatible", "poolside/laguna-m.1"), true).should.equal(true)
|
||||
isNativeToolCallingConfig(providerInfo("openai-compatible", "poolside/laguna-m.1"), false).should.equal(false)
|
||||
})
|
||||
|
||||
it("should qualify Kimi K2 and K3 models for next-gen and native tool calling paths", () => {
|
||||
for (const modelId of ["moonshotai/kimi-k2", "kimi-k2-thinking", "moonshotai/kimi-k3", "kimi-k3"]) {
|
||||
isNextGenModelFamily(modelId).should.equal(true)
|
||||
isNativeToolCallingConfig(providerInfo("openrouter", modelId), true).should.equal(true)
|
||||
isNativeToolCallingConfig(providerInfo("cline", modelId), true).should.equal(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("isGeminiFlashModel", () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user