mirror of
https://github.com/cline/cline.git
synced 2026-09-19 02:05:44 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c865b72e55 | ||
|
|
0c39279a49 | ||
|
|
d1faa633bd | ||
|
|
4623cd3e46 | ||
|
|
b314df51c2 | ||
|
|
4f12662ee0 | ||
|
|
f1c81047eb | ||
|
|
78c97ee124 | ||
|
|
527f2af2e3 | ||
|
|
0056b08300 | ||
|
|
035cce0ac5 | ||
|
|
ef76b1b977 | ||
|
|
0e1da42925 | ||
|
|
dcf78364ca | ||
|
|
4c03c79b4b | ||
|
|
9c61adbd87 | ||
|
|
5919b0fa09 | ||
|
|
9312f62e09 | ||
|
|
b4e3cb1d2b | ||
|
|
42163a1229 | ||
|
|
7337926c6e | ||
|
|
9066e6c522 | ||
|
|
e021e0777b | ||
|
|
c9d51f6332 | ||
|
|
ffe446d55d | ||
|
|
c446ca7def | ||
|
|
41418bc201 | ||
|
|
48a4016077 | ||
|
|
7e2bc6488f | ||
|
|
6ab316ad82 | ||
|
|
361f35841a | ||
|
|
e10b6e68dd | ||
|
|
713631e1ea | ||
|
|
0194c98b0f | ||
|
|
cbd73d2c1f | ||
|
|
25fd83ac4e | ||
|
|
86c61b96f6 | ||
|
|
0cba47c145 | ||
|
|
ead0f9373a | ||
|
|
1ff2fe7887 | ||
|
|
6b2c9213d0 | ||
|
|
5ee67a7f20 | ||
|
|
2a9057f184 | ||
|
|
ae3234212c | ||
|
|
24c489eceb | ||
|
|
b3ccece14e | ||
|
|
7c4cf02657 | ||
|
|
2c6de4c6a6 | ||
|
|
4d72d4e6f4 | ||
|
|
82c8f28dae | ||
|
|
b31bed5b60 | ||
|
|
ec69289e40 | ||
|
|
fed81c55b1 | ||
|
|
2fc24d30de | ||
|
|
178d3db1ab | ||
|
|
685103b193 | ||
|
|
015ece10b6 | ||
|
|
155c38f7c8 | ||
|
|
4619bb78bc | ||
|
|
7799128c42 | ||
|
|
851d3857c2 | ||
|
|
9536a39cb7 | ||
|
|
63a78519a3 | ||
|
|
91b1ecd354 | ||
|
|
1e6c2c0286 | ||
|
|
571f2fbde8 | ||
|
|
de2094f27f | ||
|
|
8f43de8c2a | ||
|
|
16a3895134 | ||
|
|
1deb0f4c52 | ||
|
|
5b71d48b31 | ||
|
|
a37cb01c8a | ||
|
|
aa7b06f5c9 | ||
|
|
2310ce56ae | ||
|
|
69976c2a23 | ||
|
|
f8d3220c86 | ||
|
|
161682d858 | ||
|
|
a7f4d4778b | ||
|
|
2f00a50fed | ||
|
|
6313920e71 | ||
|
|
f2eb188eed | ||
|
|
f8c81f1148 | ||
|
|
5360a2af4f | ||
|
|
963fac8f6d | ||
|
|
a89795fefb | ||
|
|
7addf92aea | ||
|
|
99765931db | ||
|
|
00672f2fd0 | ||
|
|
efc5d3ade5 | ||
|
|
a1b596c3ef | ||
|
|
7e917746a0 |
Vendored
+2
-22
@@ -169,8 +169,7 @@
|
||||
"isBackground": true,
|
||||
"label": "npm: watch:esbuild",
|
||||
"dependsOn": [
|
||||
"npm: protos",
|
||||
"build-sdk:debug"
|
||||
"npm: protos"
|
||||
],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
@@ -209,8 +208,7 @@
|
||||
"isBackground": true,
|
||||
"label": "npm: watch:esbuild:test",
|
||||
"dependsOn": [
|
||||
"npm: protos",
|
||||
"build-sdk:debug"
|
||||
"npm: protos"
|
||||
],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
@@ -313,24 +311,6 @@
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "build-sdk:debug",
|
||||
"type": "shell",
|
||||
"command": "bun run build:sdk",
|
||||
"problemMatcher": [
|
||||
"$tsc"
|
||||
],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
"reveal": "always"
|
||||
},
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"CLINE_SOURCEMAPS": "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
|
||||
@@ -1,21 +1,5 @@
|
||||
# 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.29",
|
||||
"version": "3.0.27",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,2 +1,36 @@
|
||||
export type { ConnectorCatalogEntry } from "@cline/shared";
|
||||
export { CONNECTOR_CATALOG, listConnectorCatalog } from "@cline/shared";
|
||||
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 }));
|
||||
}
|
||||
|
||||
@@ -27,25 +27,11 @@ 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?personal=true";
|
||||
"https://app.cline.bot/dashboard/subscription/";
|
||||
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",
|
||||
@@ -53,15 +39,6 @@ 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",
|
||||
@@ -111,7 +88,7 @@ vi.mock("./prompt", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./session-events", () => ({
|
||||
subscribeToAgentEvents: sessionEventsMocks.subscribeToAgentEvents,
|
||||
subscribeToAgentEvents: vi.fn(() => () => {}),
|
||||
}));
|
||||
|
||||
describe("runAgent", () => {
|
||||
@@ -135,9 +112,6 @@ describe("runAgent", () => {
|
||||
outputMocks.writeln.mockReset();
|
||||
outputMocks.emitJsonLine.mockReset();
|
||||
outputMocks.setActiveCliSession.mockReset();
|
||||
sessionEventsMocks.listener = undefined;
|
||||
sessionEventsMocks.subscribeToAgentEvents.mockClear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -964,121 +938,4 @@ 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,11 +18,6 @@ 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,
|
||||
@@ -189,10 +184,8 @@ export async function runAgent(
|
||||
let reasoningChunkCount = 0;
|
||||
let redactedReasoningChunkCount = 0;
|
||||
const displayedErrorMessages = new Set<string>();
|
||||
const shouldZeroCost = await shouldZeroClineFreeModelCost(config);
|
||||
|
||||
const onAgentEvent = (rawEvent: AgentEvent): void => {
|
||||
const event = zeroCliAgentEventCost(rawEvent, shouldZeroCost);
|
||||
const onAgentEvent = (event: AgentEvent): void => {
|
||||
if (event.type === "content_start" && event.contentType === "reasoning") {
|
||||
reasoningChunkCount += 1;
|
||||
if (event.redacted) {
|
||||
@@ -346,14 +339,8 @@ export async function runAgent(
|
||||
const usageSummary = await sessionManager.getAccumulatedUsage(
|
||||
started.sessionId,
|
||||
);
|
||||
const aggregateUsage = zeroCliUsageCost(
|
||||
usageSummary?.aggregateUsage,
|
||||
shouldZeroCost,
|
||||
);
|
||||
const usage = zeroCliUsageCost(
|
||||
aggregateUsage ?? usageSummary?.usage ?? result.usage,
|
||||
shouldZeroCost,
|
||||
);
|
||||
const aggregateUsage = usageSummary?.aggregateUsage;
|
||||
const usage = aggregateUsage ?? usageSummary?.usage ?? result.usage;
|
||||
|
||||
if (config.outputMode === "json") {
|
||||
emitJsonLine("stdout", {
|
||||
|
||||
@@ -24,11 +24,6 @@ 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,
|
||||
@@ -158,7 +153,6 @@ export async function runInteractive(
|
||||
askQuestionRef: tuiAskQuestion,
|
||||
});
|
||||
const providerSettingsManager = new ProviderSettingsManager();
|
||||
let zeroCurrentTurnCost = false;
|
||||
|
||||
const sessionRuntime = createInteractiveSessionRuntime({
|
||||
config,
|
||||
@@ -172,7 +166,7 @@ export async function runInteractive(
|
||||
resolveMistakeLimitDecision,
|
||||
switchToActModeTool,
|
||||
onAgentEvent: (event) => {
|
||||
uiEvents.emit("agent", zeroCliAgentEventCost(event, zeroCurrentTurnCost));
|
||||
uiEvents.emit("agent", event);
|
||||
},
|
||||
onTeamEvent: (event) => {
|
||||
uiEvents.emit("team", event);
|
||||
@@ -438,7 +432,6 @@ 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);
|
||||
@@ -485,8 +478,6 @@ export async function runInteractive(
|
||||
}
|
||||
input = chatCommandResult.input;
|
||||
commandOutput = chatCommandResult.commandOutput;
|
||||
zeroTurnCost = await shouldZeroClineFreeModelCost(config);
|
||||
zeroCurrentTurnCost = zeroTurnCost;
|
||||
const {
|
||||
prompt: userInput,
|
||||
userImages,
|
||||
@@ -528,9 +519,8 @@ export async function runInteractive(
|
||||
}
|
||||
if (result.finishReason !== "completed") {
|
||||
if (result.finishReason === "aborted" || isAbortInProgress()) {
|
||||
const usage = zeroCliUsageCost(
|
||||
await sessionRuntime.getAccumulatedUsage(result.usage),
|
||||
zeroTurnCost,
|
||||
const usage = await sessionRuntime.getAccumulatedUsage(
|
||||
result.usage,
|
||||
);
|
||||
return {
|
||||
usage,
|
||||
@@ -545,10 +535,7 @@ export async function runInteractive(
|
||||
errorText || `Turn finished with ${result.finishReason}`,
|
||||
);
|
||||
}
|
||||
const usage = zeroCliUsageCost(
|
||||
await sessionRuntime.getAccumulatedUsage(result.usage),
|
||||
zeroTurnCost,
|
||||
);
|
||||
const usage = await sessionRuntime.getAccumulatedUsage(result.usage);
|
||||
return {
|
||||
usage,
|
||||
currentContextSize: getCurrentContextSize(result.messages),
|
||||
@@ -572,7 +559,6 @@ export async function runInteractive(
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
zeroCurrentTurnCost = false;
|
||||
if (!delivery) {
|
||||
isRunning = false;
|
||||
clearAbortInProgress();
|
||||
|
||||
@@ -3,9 +3,7 @@ import type React from "react";
|
||||
import { useState } from "react";
|
||||
import "opentui-spinner/react";
|
||||
import {
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClinePassSubscriptionUrl,
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage,
|
||||
isClinePassSubscriptionError,
|
||||
} from "../../utils/cline-pass-errors";
|
||||
import {
|
||||
@@ -331,30 +329,6 @@ 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;
|
||||
@@ -453,11 +427,6 @@ 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 tokens) $0.12");
|
||||
).toBe("(12,345) $0.12");
|
||||
});
|
||||
|
||||
it("omits cost when usage cost is hidden", () => {
|
||||
@@ -67,6 +67,6 @@ describe("formatStatusBarUsageText", () => {
|
||||
totalCost: 0.123,
|
||||
showCost: false,
|
||||
}),
|
||||
).toBe("(12,345 tokens)");
|
||||
).toBe("(12,345)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ export function formatStatusBarUsageText(input: {
|
||||
totalCost: number;
|
||||
showCost: boolean;
|
||||
}): string {
|
||||
const tokens = `(${input.totalTokens.toLocaleString()} tokens)`;
|
||||
const tokens = `(${input.totalTokens.toLocaleString()})`;
|
||||
if (!input.showCost) return tokens;
|
||||
return `${tokens} ${formatCost(input.totalCost)}`;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatCliErrorMessage,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClinePassSubscriptionUrl,
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage,
|
||||
isClinePassSubscriptionError,
|
||||
} from "./cline-pass-errors";
|
||||
|
||||
@@ -22,23 +20,7 @@ describe("cline-pass-errors", () => {
|
||||
|
||||
it("formats the ClinePass subscription URL", () => {
|
||||
expect(getClinePassSubscriptionUrl()).toBe(
|
||||
"https://app.cline.bot/dashboard/subscription?personal=true",
|
||||
"https://app.cline.bot/dashboard/subscription/",
|
||||
);
|
||||
});
|
||||
|
||||
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,16 +1,10 @@
|
||||
import {
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClinePassSubscriptionUrl,
|
||||
isClineNotSubscribedError,
|
||||
isClineNotSubscribedMessage,
|
||||
isClineOrgIndividualInferenceSubscriptionError,
|
||||
isClineOrgIndividualInferenceSubscriptionMessage,
|
||||
} from "@cline/core";
|
||||
|
||||
export {
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClinePassSubscriptionUrl,
|
||||
};
|
||||
export { getClinePassSubscriptionUrl };
|
||||
|
||||
function isFormattedClinePassSubscriptionMessage(message: string): boolean {
|
||||
const normalized = message.trim().toLowerCase();
|
||||
@@ -38,30 +32,7 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
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,16 +1,325 @@
|
||||
import {
|
||||
CONNECTOR_PLATFORMS,
|
||||
shouldIncludeConnectorField,
|
||||
} from "@cline/shared";
|
||||
export interface PlatformDef {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "polling" | "webhook" | "hybrid";
|
||||
hint: string;
|
||||
fields: FieldDef[];
|
||||
security?: SecurityDef;
|
||||
}
|
||||
|
||||
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 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 const PLATFORMS = CONNECTOR_PLATFORMS;
|
||||
export const shouldIncludeField = 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,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { isIP } from "node:net";
|
||||
|
||||
export interface ClineHubServerOptions {
|
||||
host: string;
|
||||
port: number;
|
||||
@@ -49,9 +47,6 @@ 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(/\/$/, "");
|
||||
}
|
||||
@@ -90,26 +85,12 @@ 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);
|
||||
if (roomSecret) {
|
||||
url.searchParams.set("roomSecret", roomSecret);
|
||||
}
|
||||
url.searchParams.set("roomSecret", roomSecret);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
handleToolApprovalResponse,
|
||||
rejectOrphanedApprovals,
|
||||
} from "./server/approvals";
|
||||
import { isAuthorizedBrowserToDesktopRequest } from "./server/browser-auth";
|
||||
import {
|
||||
browserConfig,
|
||||
host,
|
||||
@@ -15,11 +14,7 @@ import {
|
||||
webviewDistDir,
|
||||
} from "./server/deps";
|
||||
import { handleDesktopCommand } from "./server/desktop-commands";
|
||||
import {
|
||||
createJsonResponse,
|
||||
isWebviewRoute,
|
||||
WebviewAssets,
|
||||
} from "./server/http";
|
||||
import { createJsonResponse, WebviewAssets } from "./server/http";
|
||||
import {
|
||||
attachHub,
|
||||
detachHub,
|
||||
@@ -58,33 +53,17 @@ 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 () => {
|
||||
@@ -98,21 +77,6 @@ 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 });
|
||||
}
|
||||
@@ -121,6 +85,9 @@ 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,
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
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)
|
||||
);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
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,8 +1,5 @@
|
||||
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 {
|
||||
@@ -16,68 +13,6 @@ 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),
|
||||
@@ -120,14 +55,23 @@ async function runCliConnectCommand(args: string[]): Promise<{
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}> {
|
||||
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,
|
||||
});
|
||||
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,
|
||||
},
|
||||
);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout?.setEncoding("utf8");
|
||||
@@ -213,10 +157,9 @@ export async function startConnectorChannel(
|
||||
const result = await runCliConnectCommand(cliArgs);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
normalizeConnectorError(
|
||||
result.stderr || result.stdout,
|
||||
"connector start failed",
|
||||
),
|
||||
(result.stderr.trim() || result.stdout.trim() || "connector start failed")
|
||||
.trim()
|
||||
.slice(0, 2_000),
|
||||
);
|
||||
}
|
||||
await waitForConnectorState(() =>
|
||||
@@ -225,11 +168,6 @@ export async function startConnectorChannel(
|
||||
return connectorChannelsPayload();
|
||||
}
|
||||
|
||||
export const __test__ = {
|
||||
buildCliConnectCommand,
|
||||
normalizeConnectorError,
|
||||
};
|
||||
|
||||
export async function stopConnectorChannel(
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<WebviewConnectorChannelsResponse> {
|
||||
@@ -244,10 +182,9 @@ export async function stopConnectorChannel(
|
||||
const result = await runCliConnectCommand([channel, "--stop"]);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
normalizeConnectorError(
|
||||
result.stderr || result.stdout,
|
||||
"connector stop failed",
|
||||
),
|
||||
(result.stderr.trim() || result.stdout.trim() || "connector stop failed")
|
||||
.trim()
|
||||
.slice(0, 2_000),
|
||||
);
|
||||
}
|
||||
await waitForConnectorState(
|
||||
|
||||
@@ -41,23 +41,6 @@ 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,9 +13,10 @@
|
||||
"@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",
|
||||
@@ -26,7 +27,6 @@
|
||||
"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,14 +22,7 @@ import {
|
||||
WrenchIcon,
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
lazy,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -60,24 +53,19 @@ import type {
|
||||
WebviewOutboundMessage,
|
||||
WebviewSessionSummary,
|
||||
} from "../../webview-protocol";
|
||||
import Chat from "./Chat";
|
||||
import { PageFrame, PageHeader } from "./components/views/page-layout";
|
||||
import type { CustomizationSection } from "./components/views/settings/extensions-view";
|
||||
import type { SettingsSection } from "./components/views/settings/settings-view";
|
||||
import {
|
||||
type CustomizationSection,
|
||||
CustomizationSectionView,
|
||||
} from "./components/views/settings/extensions-view";
|
||||
import {
|
||||
type SettingsSection,
|
||||
SettingsView,
|
||||
} 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"
|
||||
@@ -250,14 +238,6 @@ 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);
|
||||
@@ -1353,7 +1333,7 @@ function App() {
|
||||
|
||||
return (
|
||||
<Shell onNavigate={navigate} version={hubState.coreVersion} view={view}>
|
||||
<Suspense fallback={<ViewLoading />}>{content}</Suspense>
|
||||
{content}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,13 +11,12 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import type {
|
||||
HighlighterCore,
|
||||
LanguageRegistration,
|
||||
BundledLanguage,
|
||||
BundledTheme,
|
||||
HighlighterGeneric,
|
||||
ThemedToken,
|
||||
ThemeRegistration,
|
||||
} from "shiki/core";
|
||||
import { createHighlighterCore } from "shiki/core";
|
||||
import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
|
||||
} from "shiki";
|
||||
import { createHighlighter } from "shiki";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
@@ -37,78 +36,6 @@ 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;
|
||||
@@ -181,7 +108,7 @@ const LineSpan = ({
|
||||
// Types
|
||||
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
|
||||
code: string;
|
||||
language: string;
|
||||
language: BundledLanguage;
|
||||
showLineNumbers?: boolean;
|
||||
};
|
||||
|
||||
@@ -201,9 +128,10 @@ const CodeBlockContext = createContext<CodeBlockContextType>({
|
||||
});
|
||||
|
||||
// Highlighter cache (singleton per language)
|
||||
let highlighterPromise: Promise<HighlighterCore> | undefined;
|
||||
let themesPromise: Promise<void> | undefined;
|
||||
const languagePromises = new Map<SupportedCodeLanguage, Promise<void>>();
|
||||
const highlighterCache = new Map<
|
||||
string,
|
||||
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
|
||||
>();
|
||||
|
||||
// Token cache
|
||||
const tokensCache = new Map<string, TokenizedCode>();
|
||||
@@ -211,44 +139,27 @@ 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: string) => {
|
||||
const getTokensCacheKey = (code: string, language: BundledLanguage) => {
|
||||
const start = code.slice(0, 100);
|
||||
const end = code.length > 100 ? code.slice(-100) : "";
|
||||
return `${language}:${code.length}:${start}:${end}`;
|
||||
};
|
||||
|
||||
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);
|
||||
const getHighlighter = (
|
||||
language: BundledLanguage,
|
||||
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
|
||||
const cached = highlighterCache.get(language);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const languagePromise = LANGUAGE_LOADERS[language]().then((registrations) =>
|
||||
highlighter.loadLanguage(...registrations),
|
||||
);
|
||||
languagePromises.set(language, languagePromise);
|
||||
return languagePromise;
|
||||
|
||||
const highlighterPromise = createHighlighter({
|
||||
langs: [language],
|
||||
themes: ["github-light", "github-dark"],
|
||||
});
|
||||
|
||||
highlighterCache.set(language, highlighterPromise);
|
||||
return highlighterPromise;
|
||||
};
|
||||
|
||||
// Create raw tokens for immediate display while highlighting loads
|
||||
@@ -270,16 +181,11 @@ const createRawTokens = (code: string): TokenizedCode => ({
|
||||
// Synchronous highlight with callback for async results
|
||||
export const highlightCode = (
|
||||
code: string,
|
||||
language: string,
|
||||
language: BundledLanguage,
|
||||
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
|
||||
callback?: (result: TokenizedCode) => void,
|
||||
): TokenizedCode | null => {
|
||||
const langToUse = normalizeLanguage(language);
|
||||
if (langToUse === "text") {
|
||||
return createRawTokens(code);
|
||||
}
|
||||
|
||||
const tokensCacheKey = getTokensCacheKey(code, langToUse);
|
||||
const tokensCacheKey = getTokensCacheKey(code, language);
|
||||
|
||||
// Return cached result if available
|
||||
const cached = tokensCache.get(tokensCacheKey);
|
||||
@@ -296,11 +202,11 @@ export const highlightCode = (
|
||||
}
|
||||
|
||||
// Start highlighting in background - fire-and-forget async pattern
|
||||
getHighlighter()
|
||||
getHighlighter(language)
|
||||
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then)
|
||||
.then(async (highlighter) => {
|
||||
await ensureThemes(highlighter);
|
||||
await ensureLanguage(highlighter, langToUse);
|
||||
.then((highlighter) => {
|
||||
const availableLangs = highlighter.getLoadedLanguages();
|
||||
const langToUse = availableLangs.includes(language) ? language : "text";
|
||||
|
||||
const result = highlighter.codeToTokens(code, {
|
||||
lang: langToUse,
|
||||
@@ -470,7 +376,7 @@ export const CodeBlockContent = ({
|
||||
showLineNumbers = false,
|
||||
}: {
|
||||
code: string;
|
||||
language: string;
|
||||
language: BundledLanguage;
|
||||
showLineNumbers?: boolean;
|
||||
}) => {
|
||||
// Memoized raw tokens for immediate display
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"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";
|
||||
@@ -12,6 +16,7 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group";
|
||||
import {
|
||||
@@ -21,7 +26,6 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HubStreamdown } from "./streamdown";
|
||||
|
||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
@@ -312,18 +316,24 @@ export const MessageBranchPage = ({
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageResponseProps = ComponentProps<typeof HubStreamdown>;
|
||||
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
|
||||
|
||||
const streamdownPlugins = { cjk, code, math, mermaid };
|
||||
|
||||
export const MessageResponse = memo(
|
||||
({ className, ...props }: MessageResponseProps) => (
|
||||
<HubStreamdown
|
||||
<Streamdown
|
||||
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,6 +1,10 @@
|
||||
"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 {
|
||||
@@ -13,6 +17,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
@@ -21,7 +26,6 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Shimmer } from "./shimmer";
|
||||
import { HubStreamdown } from "./streamdown";
|
||||
|
||||
interface ReasoningContextValue {
|
||||
isStreaming: boolean;
|
||||
@@ -200,6 +204,8 @@ export type ReasoningContentProps = ComponentProps<
|
||||
children: string;
|
||||
};
|
||||
|
||||
const streamdownPlugins = { cjk, code, math, mermaid };
|
||||
|
||||
export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
@@ -210,7 +216,7 @@ export const ReasoningContent = memo(
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<HubStreamdown>{children}</HubStreamdown>
|
||||
<Streamdown plugins={streamdownPlugins}>{children}</Streamdown>
|
||||
</CollapsibleContent>
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
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,7 +3,6 @@ import {
|
||||
Puzzle,
|
||||
Search,
|
||||
Server,
|
||||
Star,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
@@ -27,7 +26,6 @@ import {
|
||||
type MarketplacePrimitiveType,
|
||||
type MarketplaceTag,
|
||||
} from "@/lib/marketplace";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CommandBadge, PageFrame, PageHeader } from "./page-layout";
|
||||
|
||||
type EntryActionState =
|
||||
@@ -116,13 +114,6 @@ 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();
|
||||
}
|
||||
@@ -266,8 +257,6 @@ function MarketplaceEntryCard({
|
||||
onToggleExpanded,
|
||||
onUninstall,
|
||||
matchedLocalItems = [],
|
||||
showFeatured = true,
|
||||
showTags = true,
|
||||
sourceLabel,
|
||||
tagLabels,
|
||||
}: {
|
||||
@@ -280,8 +269,6 @@ function MarketplaceEntryCard({
|
||||
onToggleExpanded: (entry: MarketplaceEntry) => void;
|
||||
onUninstall: (entry: MarketplaceEntry) => void;
|
||||
matchedLocalItems?: MarketplaceLocalInstalledItem[];
|
||||
showFeatured?: boolean;
|
||||
showTags?: boolean;
|
||||
sourceLabel?: string;
|
||||
tagLabels: Map<string, string>;
|
||||
}) {
|
||||
@@ -312,63 +299,15 @@ 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={cn(
|
||||
"flex min-w-0 flex-1 items-center gap-2",
|
||||
!installed && "pr-24",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<EntryIcon className="h-4 w-4 shrink-0 text-primary" />
|
||||
<h2 className="min-w-0 truncate text-sm font-semibold text-foreground">
|
||||
<h2 className="min-w-0 flex-1 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 ? (
|
||||
@@ -410,34 +349,23 @@ function MarketplaceEntryCard({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<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}
|
||||
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||
{entry.description}
|
||||
</p>
|
||||
|
||||
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||
{entry.description}
|
||||
</p>
|
||||
{statusMessage}
|
||||
{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>
|
||||
|
||||
{installed ? (
|
||||
<div className="flex shrink-0 flex-col items-start gap-1 sm:items-end sm:justify-end">
|
||||
{actionButton}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{matchedLocalItems.some((item) => item.renderMatchedDetails) ? (
|
||||
<div className="grid gap-2" data-marketplace-entry-details>
|
||||
@@ -449,6 +377,36 @@ 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}
|
||||
@@ -457,7 +415,7 @@ function MarketplaceEntryCard({
|
||||
|
||||
if (!hasExpandableDetails) {
|
||||
return (
|
||||
<div className="relative grid gap-2 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20">
|
||||
<div className="grid gap-3 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
@@ -468,7 +426,7 @@ function MarketplaceEntryCard({
|
||||
<div
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${expanded ? "Collapse" : "Expand"} ${entry.name}`}
|
||||
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"
|
||||
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"
|
||||
onClick={(event) => {
|
||||
if (
|
||||
event.target instanceof HTMLElement &&
|
||||
@@ -529,7 +487,6 @@ function MarketplaceSection({
|
||||
emptyMessage,
|
||||
entries,
|
||||
expandedEntryKey,
|
||||
headerContent,
|
||||
installedEntryKeys,
|
||||
installedStatusReady,
|
||||
localOnlyInstalledItems = [],
|
||||
@@ -537,8 +494,6 @@ function MarketplaceSection({
|
||||
onInstall,
|
||||
onToggleExpanded,
|
||||
onUninstall,
|
||||
showFeaturedBadges = true,
|
||||
showEntryTags = true,
|
||||
sourceLabel,
|
||||
tagLabels,
|
||||
title,
|
||||
@@ -547,7 +502,6 @@ function MarketplaceSection({
|
||||
emptyMessage: string;
|
||||
entries: MarketplaceEntry[];
|
||||
expandedEntryKey: string | null;
|
||||
headerContent?: ReactNode;
|
||||
installedEntryKeys: Set<string>;
|
||||
installedStatusReady: boolean;
|
||||
localOnlyInstalledItems?: MarketplaceLocalInstalledItem[];
|
||||
@@ -555,8 +509,6 @@ 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;
|
||||
@@ -568,7 +520,6 @@ 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())}
|
||||
@@ -586,8 +537,6 @@ function MarketplaceSection({
|
||||
onToggleExpanded={onToggleExpanded}
|
||||
onUninstall={onUninstall}
|
||||
matchedLocalItems={matchedLocalItemsByEntryKey?.get(key) ?? []}
|
||||
showFeatured={showFeaturedBadges}
|
||||
showTags={showEntryTags}
|
||||
sourceLabel={sourceLabel}
|
||||
tagLabels={tagLabels}
|
||||
/>
|
||||
@@ -677,48 +626,19 @@ export function MarketplaceView({
|
||||
);
|
||||
|
||||
const primitiveEntries = useMemo(
|
||||
() =>
|
||||
(catalog?.entries.filter((entry) => entry.type === primitive) ?? []).sort(
|
||||
compareFeaturedEntries,
|
||||
),
|
||||
() => catalog?.entries.filter((entry) => entry.type === primitive) ?? [],
|
||||
[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 marketplaceEntriesBeforeTag) {
|
||||
for (const entry of primitiveEntries) {
|
||||
for (const tag of entry.tags) {
|
||||
counts.set(tag, (counts.get(tag) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}, [marketplaceEntriesBeforeTag]);
|
||||
}, [primitiveEntries]);
|
||||
|
||||
const primitiveTags = useMemo(
|
||||
() =>
|
||||
@@ -726,20 +646,26 @@ export function MarketplaceView({
|
||||
[catalog?.tags, tagCounts],
|
||||
);
|
||||
|
||||
const catalogEntries = useMemo(
|
||||
() =>
|
||||
marketplaceEntriesBeforeTag.filter(
|
||||
(entry) => !selectedTag || entry.tags.includes(selectedTag),
|
||||
),
|
||||
[marketplaceEntriesBeforeTag, selectedTag],
|
||||
);
|
||||
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 matchedLocalItemsByEntryKey = useMemo(() => {
|
||||
const matched = new Map<string, MarketplaceLocalInstalledItem[]>();
|
||||
for (const item of installedItems ?? []) {
|
||||
for (const entry of installedEntries) {
|
||||
for (const entry of filteredEntries) {
|
||||
const key = entryKey(entry);
|
||||
if (!entryMatchesLocalItem(entry, item)) {
|
||||
if (
|
||||
!installedEntryKeys.has(key) ||
|
||||
!entryMatchesLocalItem(entry, item)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const items = matched.get(key) ?? [];
|
||||
@@ -748,78 +674,49 @@ export function MarketplaceView({
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}, [installedEntries, installedItems]);
|
||||
}, [filteredEntries, installedEntryKeys, installedItems]);
|
||||
|
||||
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 matchedLocalItemKeys = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
[...matchedLocalItemsByEntryKey.values()].flatMap((items) =>
|
||||
items.map((item) => item.key),
|
||||
),
|
||||
),
|
||||
[matchedLocalItemsByEntryKey],
|
||||
);
|
||||
|
||||
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 localOnlyInstalledItems = useMemo(
|
||||
() =>
|
||||
(installedItems ?? []).filter(
|
||||
(item) => !matchedLocalItemKeys.has(item.key),
|
||||
),
|
||||
[installedItems, matchedLocalItemKeys],
|
||||
);
|
||||
|
||||
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 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 clearFilters = () => {
|
||||
setQuery("");
|
||||
setSelectedTag(null);
|
||||
};
|
||||
|
||||
const setEntryState = (entry: MarketplaceEntry, state: EntryActionState) => {
|
||||
const key = entryKey(entry);
|
||||
@@ -966,7 +863,43 @@ 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
|
||||
@@ -981,8 +914,6 @@ export function MarketplaceView({
|
||||
onInstall={installEntry}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
onUninstall={uninstallEntry}
|
||||
showFeaturedBadges={false}
|
||||
showEntryTags={false}
|
||||
sourceLabel="Marketplace"
|
||||
tagLabels={tagLabels}
|
||||
title="Installed"
|
||||
@@ -993,7 +924,6 @@ 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 marketplace.",
|
||||
Skills: "Manage installed skills and add new skills from the marketplace.",
|
||||
MCP: "Manage installed MCP servers and add new servers from the catalog.",
|
||||
Skills: "Manage installed skills and add new skills from the catalog.",
|
||||
Agents: "Review configured agents discovered from local settings.",
|
||||
Plugins: "Manage installed plugins and add new plugins from the marketplace.",
|
||||
Plugins: "Manage installed plugins and add new plugins from the catalog.",
|
||||
Tools: "Inspect built-in tools and tools contributed by plugins.",
|
||||
};
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ export type MarketplaceEntry = {
|
||||
id: string;
|
||||
type: MarketplacePrimitiveType;
|
||||
name: string;
|
||||
featured?: boolean;
|
||||
tagline: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
@@ -101,7 +100,7 @@ export async function fetchMarketplaceCatalog(): Promise<MarketplaceCatalog> {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch marketplace: ${response.status}`);
|
||||
throw new Error(`Failed to fetch marketplace catalog: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
const baseUrl = typeof data?.baseUrl === "string" ? data.baseUrl : undefined;
|
||||
@@ -153,10 +152,6 @@ 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,27 +3,6 @@ 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: {
|
||||
@@ -46,13 +25,5 @@ export default defineConfig({
|
||||
outDir: "../../dist/webview",
|
||||
emptyOutDir: true,
|
||||
cssMinify: "esbuild",
|
||||
chunkSizeWarningLimit: 600,
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
codeSplitting: {
|
||||
groups: mermaidChunkGroups,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
<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>
|
||||
|
||||
@@ -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": "4.0.0",
|
||||
"version": "3.89.0",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
@@ -346,8 +346,8 @@
|
||||
"compile": "bun run check-types && bun run lint && bun esbuild.mjs",
|
||||
"compile-standalone": "bun run check-types && bun run lint && bun esbuild.mjs --standalone",
|
||||
"postcompile-standalone": "node scripts/package-standalone.mjs",
|
||||
"dev": "bun run protos && bun run watch",
|
||||
"watch": "bun run --parallel watch:esbuild watch:tsc",
|
||||
"dev": "bun run watch",
|
||||
"watch": "bun run protos && bun run --parallel watch:esbuild watch:tsc",
|
||||
"watch:esbuild": "bun esbuild.mjs --watch",
|
||||
"watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
|
||||
"package": "bun run check-types && bun run build:webview && bun run lint && bun esbuild.mjs --production",
|
||||
|
||||
@@ -135,6 +135,8 @@ message ProviderListing {
|
||||
optional string default_model_id = 3;
|
||||
optional string family = 4;
|
||||
optional string protocol = 5;
|
||||
// SDK/core-owned auth classification. Values: "api-key", "oauth", "local".
|
||||
optional string auth_method = 17;
|
||||
optional string auth_description = 6;
|
||||
optional string base_url_description = 7;
|
||||
bool allows_custom_model_ids = 8;
|
||||
@@ -144,6 +146,26 @@ message ProviderListing {
|
||||
// `@cline/llms`. When "hide", consumers must suppress per-token pricing
|
||||
// and total cost displays (matches the CLI's `shouldShowCliUsageCost`).
|
||||
string usage_cost_display = 11;
|
||||
repeated ProviderConfigField config_fields = 15;
|
||||
map<string, string> config_values_json = 16;
|
||||
}
|
||||
|
||||
message ProviderConfigFieldOption {
|
||||
string label = 1;
|
||||
string value = 2;
|
||||
optional string value_json = 3;
|
||||
}
|
||||
|
||||
message ProviderConfigField {
|
||||
string path = 1;
|
||||
string label = 2;
|
||||
string type = 3;
|
||||
optional string placeholder = 4;
|
||||
optional string description = 5;
|
||||
bool secret = 6;
|
||||
bool required = 7;
|
||||
repeated ProviderConfigFieldOption options = 8;
|
||||
optional string default_value_json = 9;
|
||||
}
|
||||
|
||||
message ProviderListingsResponse {
|
||||
@@ -249,6 +271,11 @@ message WriteProviderConfigPatch {
|
||||
optional bool clear_headers = 10;
|
||||
optional AwsProviderConfig aws = 11;
|
||||
optional GcpProviderConfig gcp = 12;
|
||||
// Generic SDK/core provider settings patch, encoded as JSON object and
|
||||
// merged by the provider settings service. This lets the webview render and
|
||||
// persist SDK-provided config field paths without hardcoding provider shapes
|
||||
// into the proto.
|
||||
optional string settings_json = 13;
|
||||
}
|
||||
|
||||
message WriteProviderConfigRequest {
|
||||
@@ -398,6 +425,7 @@ message ModelsApiOptions {
|
||||
|
||||
// Plan mode configurations
|
||||
optional ApiProvider plan_mode_api_provider = 100;
|
||||
optional string plan_mode_api_provider_id = 190;
|
||||
optional string plan_mode_api_model_id = 101;
|
||||
optional int64 plan_mode_thinking_budget_tokens = 102;
|
||||
optional string plan_mode_reasoning_effort = 103;
|
||||
@@ -439,6 +467,7 @@ message ModelsApiOptions {
|
||||
|
||||
// Act mode configurations
|
||||
optional ApiProvider act_mode_api_provider = 200;
|
||||
optional string act_mode_api_provider_id = 290;
|
||||
optional string act_mode_api_model_id = 201;
|
||||
optional int64 act_mode_thinking_budget_tokens = 202;
|
||||
optional string act_mode_reasoning_effort = 203;
|
||||
@@ -613,12 +642,6 @@ enum ApiProvider {
|
||||
OPENAI_CODEX = 40;
|
||||
WANDB = 41;
|
||||
CLINE_PASS = 42;
|
||||
POOLSIDE = 45;
|
||||
V0 = 46;
|
||||
XIAOMI = 47;
|
||||
ZAI_CODING_PLAN = 49;
|
||||
reserved 43, 44, 48;
|
||||
reserved "OPENAI_CODEX_CLI", "OPENCODE", "KILO";
|
||||
}
|
||||
|
||||
enum ApiFormat {
|
||||
@@ -761,6 +784,7 @@ message ModelsApiConfiguration {
|
||||
|
||||
// Plan mode configurations
|
||||
optional ApiProvider plan_mode_api_provider = 100;
|
||||
optional string plan_mode_api_provider_id = 190;
|
||||
optional string plan_mode_api_model_id = 101;
|
||||
optional int64 plan_mode_thinking_budget_tokens = 102;
|
||||
optional string plan_mode_reasoning_effort = 103;
|
||||
@@ -807,6 +831,7 @@ message ModelsApiConfiguration {
|
||||
|
||||
// Act mode configurations
|
||||
optional ApiProvider act_mode_api_provider = 200;
|
||||
optional string act_mode_api_provider_id = 290;
|
||||
optional string act_mode_api_model_id = 201;
|
||||
optional int64 act_mode_thinking_budget_tokens = 202;
|
||||
optional string act_mode_reasoning_effort = 203;
|
||||
|
||||
@@ -285,6 +285,10 @@ message Settings {
|
||||
optional string act_mode_cline_model_id = 180;
|
||||
optional OpenRouterModelInfo act_mode_cline_model_info = 181;
|
||||
optional bool show_feature_tips = 182;
|
||||
optional string plan_mode_cline_pass_model_id = 183;
|
||||
optional OpenRouterModelInfo plan_mode_cline_pass_model_info = 184;
|
||||
optional string act_mode_cline_pass_model_id = 185;
|
||||
optional OpenRouterModelInfo act_mode_cline_pass_model_info = 186;
|
||||
}
|
||||
|
||||
message State {
|
||||
|
||||
+2
-2
@@ -25,7 +25,7 @@ describe("clearOrganizationForClinePassProviderSelection", () => {
|
||||
} as unknown as Controller
|
||||
}
|
||||
|
||||
it("does nothing when ClinePass is not selected", async () => {
|
||||
it("does nothing when Cline Pass 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 ClinePass is selected", async () => {
|
||||
it("switches to the personal account when Cline Pass is selected", async () => {
|
||||
await clearOrganizationForClinePassProviderSelection(createController(), {
|
||||
planModeApiProvider: "cline-pass",
|
||||
actModeApiProvider: "openrouter",
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import type { ApiConfiguration } from "@shared/api"
|
||||
import { ApiFormat, OpenRouterModelInfo } from "@shared/proto/cline/models"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import type { EffectiveProviderConfig, ProviderCatalog, ProviderConfigStore } from "@/sdk/model-catalog/contracts"
|
||||
import type {
|
||||
EffectiveProviderConfig,
|
||||
ProviderCatalog,
|
||||
ProviderConfigStore,
|
||||
ProviderListing,
|
||||
} from "@/sdk/model-catalog/contracts"
|
||||
import { computeConfigFingerprint } from "@/sdk/model-catalog/fingerprint"
|
||||
import { parseProviderId } from "@/sdk/model-catalog/provider-id"
|
||||
import { ApiFormat, OpenRouterModelInfo } from "@/shared/proto/cline/models"
|
||||
import type { ProviderCatalogController } from "../providerCatalogShared"
|
||||
|
||||
type TestStateManager = {
|
||||
setGlobalStateBatch: ReturnType<typeof vi.fn>
|
||||
setGlobalStateBatch?: ReturnType<typeof vi.fn>
|
||||
getApiConfiguration?: ReturnType<typeof vi.fn<() => ApiConfiguration | undefined>>
|
||||
getRemoteConfigSettings?: ReturnType<typeof vi.fn<() => Record<string, unknown>>>
|
||||
}
|
||||
|
||||
function makeStore(config: EffectiveProviderConfig): ProviderConfigStore {
|
||||
@@ -21,16 +27,30 @@ function makeStore(config: EffectiveProviderConfig): ProviderConfigStore {
|
||||
}
|
||||
}
|
||||
|
||||
function makeCatalog(): ProviderCatalog {
|
||||
function makeCatalog(providers: ProviderListing[] = []): ProviderCatalog {
|
||||
return {
|
||||
listProviders: vi.fn(async () => []),
|
||||
invalidateProviderListings: vi.fn(),
|
||||
listProviders: vi.fn(async () => providers),
|
||||
resolveModels: vi.fn(),
|
||||
peekModels: vi.fn(),
|
||||
subscribe: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
}
|
||||
}
|
||||
|
||||
function makeProviderListing(id: string, paths: string[]): ProviderListing {
|
||||
return {
|
||||
id: parseProviderId(id),
|
||||
name: id,
|
||||
defaultModelId: "test-model",
|
||||
configFields: paths.map((path) => ({
|
||||
path,
|
||||
label: path,
|
||||
type: path === "apiKey" || path.endsWith("secretKey") ? "password" : "text",
|
||||
})),
|
||||
allowsCustomModelIds: false,
|
||||
usageCostDisplay: "show",
|
||||
}
|
||||
}
|
||||
|
||||
function makeController(
|
||||
store: ProviderConfigStore,
|
||||
catalog: ProviderCatalog,
|
||||
@@ -62,6 +82,40 @@ describe("provider model catalog handlers", () => {
|
||||
defaultModelId: "deepseek-v4-flash",
|
||||
protocol: "openai-chat",
|
||||
authDescription: "DeepSeek models",
|
||||
configFields: [
|
||||
{
|
||||
path: "apiKey",
|
||||
label: "API Key",
|
||||
type: "password",
|
||||
secret: true,
|
||||
},
|
||||
{
|
||||
path: "aws.authentication",
|
||||
label: "Authentication",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "AWS SDK / IAM", value: "iam" },
|
||||
{ label: "API Key", value: "api-key" },
|
||||
],
|
||||
defaultValue: "iam",
|
||||
},
|
||||
{
|
||||
path: "aws.useGlobalInference",
|
||||
label: "Global Inference",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
},
|
||||
],
|
||||
configValues: {
|
||||
apiKey: "SECRET_SENTINEL_LISTING_API_KEY",
|
||||
baseUrl: "https://api.deepseek.com/v1",
|
||||
headers: {
|
||||
authorization: "Bearer SECRET_SENTINEL_HEADER",
|
||||
"x-safe-header": "visible",
|
||||
},
|
||||
"aws.authentication": "api-key",
|
||||
"aws.useGlobalInference": true,
|
||||
},
|
||||
allowsCustomModelIds: false,
|
||||
usageCostDisplay: "show",
|
||||
},
|
||||
@@ -79,13 +133,81 @@ describe("provider model catalog handlers", () => {
|
||||
protocol: "openai-chat",
|
||||
authDescription: "DeepSeek models",
|
||||
baseUrlDescription: undefined,
|
||||
configFields: [
|
||||
{
|
||||
path: "apiKey",
|
||||
label: "API Key",
|
||||
type: "password",
|
||||
placeholder: undefined,
|
||||
description: undefined,
|
||||
secret: true,
|
||||
required: false,
|
||||
options: [],
|
||||
defaultValueJson: undefined,
|
||||
},
|
||||
{
|
||||
path: "aws.authentication",
|
||||
label: "Authentication",
|
||||
type: "select",
|
||||
placeholder: undefined,
|
||||
description: undefined,
|
||||
secret: false,
|
||||
required: false,
|
||||
options: [
|
||||
{ label: "AWS SDK / IAM", value: "iam", valueJson: '"iam"' },
|
||||
{ label: "API Key", value: "api-key", valueJson: '"api-key"' },
|
||||
],
|
||||
defaultValueJson: '"iam"',
|
||||
},
|
||||
{
|
||||
path: "aws.useGlobalInference",
|
||||
label: "Global Inference",
|
||||
type: "boolean",
|
||||
placeholder: undefined,
|
||||
description: undefined,
|
||||
secret: false,
|
||||
required: false,
|
||||
options: [],
|
||||
defaultValueJson: "false",
|
||||
},
|
||||
],
|
||||
configValuesJson: {
|
||||
baseUrl: '"https://api.deepseek.com/v1"',
|
||||
headers: '{"authorization":"","x-safe-header":"visible"}',
|
||||
"aws.authentication": '"api-key"',
|
||||
"aws.useGlobalInference": "true",
|
||||
},
|
||||
allowsCustomModelIds: false,
|
||||
usageCostDisplay: "show",
|
||||
},
|
||||
])
|
||||
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL")
|
||||
expect(catalog.listProviders).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("readProviderConfig redacts sensitive custom header values", async () => {
|
||||
const { readProviderConfig } = await import("../readProviderConfig")
|
||||
const providerId = parseProviderId("openai-compatible")
|
||||
const store = makeStore({
|
||||
providerId,
|
||||
headers: {
|
||||
authorization: "Bearer SECRET_SENTINEL_AUTH_HEADER",
|
||||
cookie: "SECRET_SENTINEL_COOKIE",
|
||||
"x-safe-header": "visible",
|
||||
},
|
||||
})
|
||||
const controller = makeController(store, makeCatalog())
|
||||
|
||||
const response = await readProviderConfig(controller, { value: "openai-compatible" })
|
||||
|
||||
expect(response.headers).toEqual({
|
||||
authorization: "",
|
||||
cookie: "",
|
||||
"x-safe-header": "visible",
|
||||
})
|
||||
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL")
|
||||
})
|
||||
|
||||
it("resolveProviderModels returns full protobuf model metadata and request id", async () => {
|
||||
const { resolveProviderModels } = await import("../resolveProviderModels")
|
||||
const providerId = parseProviderId("deepseek")
|
||||
@@ -176,7 +298,12 @@ describe("provider model catalog handlers", () => {
|
||||
baseUrl: "http://localhost:11434/v1",
|
||||
}
|
||||
const store = makeStore(updatedConfig)
|
||||
const controller = makeController(store, makeCatalog())
|
||||
const controller = makeController(
|
||||
store,
|
||||
makeCatalog([
|
||||
makeProviderListing("bedrock", ["apiKey", "aws.authentication", "aws.region", "aws.customModelBaseId"]),
|
||||
]),
|
||||
)
|
||||
|
||||
const response = await writeProviderConfig(controller, {
|
||||
providerId: "ollama",
|
||||
@@ -191,6 +318,126 @@ describe("provider model catalog handlers", () => {
|
||||
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL")
|
||||
})
|
||||
|
||||
it("writeProviderConfig routes settings_json through the provider config store", async () => {
|
||||
const { writeProviderConfig } = await import("../writeProviderConfig")
|
||||
const providerId = parseProviderId("bedrock")
|
||||
const updatedConfig: EffectiveProviderConfig = {
|
||||
providerId,
|
||||
apiKey: "SECRET_SENTINEL_BEDROCK",
|
||||
aws: {
|
||||
authentication: "api-key",
|
||||
region: "us-east-2",
|
||||
customModelBaseId: "base-profile",
|
||||
},
|
||||
}
|
||||
const store = makeStore(updatedConfig)
|
||||
const controller = makeController(
|
||||
store,
|
||||
makeCatalog([
|
||||
makeProviderListing("bedrock", ["apiKey", "aws.authentication", "aws.region", "aws.customModelBaseId"]),
|
||||
]),
|
||||
)
|
||||
|
||||
const response = await writeProviderConfig(controller, {
|
||||
providerId: "bedrock",
|
||||
patch: {
|
||||
headers: {},
|
||||
settingsJson: JSON.stringify({
|
||||
apiKey: "SECRET_SENTINEL_BEDROCK",
|
||||
aws: {
|
||||
authentication: "api-key",
|
||||
region: "us-east-2",
|
||||
customModelBaseId: "base-profile",
|
||||
},
|
||||
auth: {
|
||||
accessToken: "SECRET_SENTINEL_UNADVERTISED_TOKEN",
|
||||
},
|
||||
extras: {
|
||||
unadvertised: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
expect(store.write).toHaveBeenCalledWith(providerId, {
|
||||
settings: {
|
||||
apiKey: "SECRET_SENTINEL_BEDROCK",
|
||||
aws: {
|
||||
authentication: "api-key",
|
||||
region: "us-east-2",
|
||||
customModelBaseId: "base-profile",
|
||||
},
|
||||
},
|
||||
apiKey: "SECRET_SENTINEL_BEDROCK",
|
||||
aws: {
|
||||
authentication: "api-key",
|
||||
region: "us-east-2",
|
||||
customModelBaseId: "base-profile",
|
||||
},
|
||||
})
|
||||
expect(response.apiKeyLength).toBe("SECRET_SENTINEL_BEDROCK".length)
|
||||
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL")
|
||||
expect(JSON.stringify(vi.mocked(store.write).mock.calls)).not.toContain("UNADVERTISED")
|
||||
})
|
||||
|
||||
it("writeProviderConfig ignores settings_json writes for remotely locked provider fields", async () => {
|
||||
const { writeProviderConfig } = await import("../writeProviderConfig")
|
||||
const providerId = parseProviderId("openai-compatible")
|
||||
const updatedConfig: EffectiveProviderConfig = {
|
||||
providerId,
|
||||
apiKey: "SECRET_SENTINEL_OPENAI",
|
||||
baseUrl: "https://remote.example/v1",
|
||||
}
|
||||
const store = makeStore(updatedConfig)
|
||||
const controller = makeController(
|
||||
store,
|
||||
makeCatalog([makeProviderListing("openai-compatible", ["apiKey", "baseUrl", "headers", "azure.apiVersion"])]),
|
||||
{
|
||||
getRemoteConfigSettings: vi.fn(() => ({
|
||||
remoteConfiguredProviders: ["openai-compatible"],
|
||||
openAiBaseUrl: "https://remote.example/v1",
|
||||
openAiHeaders: { "x-remote": "locked" },
|
||||
azureApiVersion: "2026-01-01-preview",
|
||||
})),
|
||||
},
|
||||
)
|
||||
|
||||
await writeProviderConfig(controller, {
|
||||
providerId: "openai-compatible",
|
||||
patch: {
|
||||
headers: {},
|
||||
settingsJson: JSON.stringify({
|
||||
apiKey: "SECRET_SENTINEL_OPENAI",
|
||||
baseUrl: "https://local.example/v1",
|
||||
headers: { "x-local": "blocked" },
|
||||
azure: { apiVersion: "2024-02-15-preview" },
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
expect(store.write).toHaveBeenCalledWith(providerId, {
|
||||
settings: {
|
||||
apiKey: "SECRET_SENTINEL_OPENAI",
|
||||
},
|
||||
apiKey: "SECRET_SENTINEL_OPENAI",
|
||||
})
|
||||
})
|
||||
|
||||
it("writeProviderConfig rejects non-object settings_json payloads", async () => {
|
||||
const { writeProviderConfig } = await import("../writeProviderConfig")
|
||||
const providerId = parseProviderId("deepseek")
|
||||
const store = makeStore({ providerId })
|
||||
const controller = makeController(store, makeCatalog())
|
||||
|
||||
await expect(
|
||||
writeProviderConfig(controller, {
|
||||
providerId: "deepseek",
|
||||
patch: { headers: {}, settingsJson: JSON.stringify(["not", "an", "object"]) },
|
||||
}),
|
||||
).rejects.toThrow("settings_json must be a JSON object")
|
||||
expect(store.write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("writeProviderConfig can explicitly clear headers", async () => {
|
||||
const { writeProviderConfig } = await import("../writeProviderConfig")
|
||||
const providerId = parseProviderId("openai")
|
||||
@@ -199,7 +446,7 @@ describe("provider model catalog handlers", () => {
|
||||
headers: {},
|
||||
}
|
||||
const store = makeStore(updatedConfig)
|
||||
const controller = makeController(store, makeCatalog())
|
||||
const controller = makeController(store, makeCatalog([makeProviderListing("openai", ["headers"])]))
|
||||
|
||||
await writeProviderConfig(controller, {
|
||||
providerId: "openai",
|
||||
@@ -209,6 +456,26 @@ describe("provider model catalog handlers", () => {
|
||||
expect(store.write).toHaveBeenCalledWith(providerId, { headers: {} })
|
||||
})
|
||||
|
||||
it("writeProviderConfig ignores direct webview auth and unadvertised header writes", async () => {
|
||||
const { writeProviderConfig } = await import("../writeProviderConfig")
|
||||
const providerId = parseProviderId("deepseek")
|
||||
const store = makeStore({ providerId })
|
||||
const controller = makeController(store, makeCatalog([makeProviderListing("deepseek", ["apiKey"])]))
|
||||
|
||||
await writeProviderConfig(controller, {
|
||||
providerId: "deepseek",
|
||||
patch: {
|
||||
apiKey: "SECRET_SENTINEL_API_KEY",
|
||||
headers: { authorization: "Bearer SECRET_SENTINEL_HEADER" },
|
||||
accessToken: "SECRET_SENTINEL_ACCESS",
|
||||
},
|
||||
})
|
||||
|
||||
expect(store.write).toHaveBeenCalledWith(providerId, {
|
||||
apiKey: "SECRET_SENTINEL_API_KEY",
|
||||
})
|
||||
})
|
||||
|
||||
it("commitModelSelection validates mode and commits the full selection envelope", async () => {
|
||||
const { commitModelSelection } = await import("../commitModelSelection")
|
||||
const providerId = parseProviderId("deepseek")
|
||||
@@ -239,11 +506,117 @@ describe("provider model catalog handlers", () => {
|
||||
}),
|
||||
})
|
||||
expect(stateManager.setGlobalStateBatch).toHaveBeenCalledWith({
|
||||
planModeApiProvider: "deepseek",
|
||||
planModeApiModelId: "deepseek-v4-flash",
|
||||
actModeApiProvider: "deepseek",
|
||||
actModeApiModelId: "deepseek-v4-flash",
|
||||
})
|
||||
})
|
||||
|
||||
it("commitModelSelection persists SAP deployment metadata from SDK-owned model discovery", async () => {
|
||||
const { commitModelSelection } = await import("../commitModelSelection")
|
||||
const providerId = parseProviderId("sapaicore")
|
||||
const store = makeStore({ providerId })
|
||||
const catalog = makeCatalog()
|
||||
const modelInfo = {
|
||||
name: "Claude Sonnet deployment",
|
||||
contextWindow: 128_000,
|
||||
supportsPromptCache: true,
|
||||
metadata: {
|
||||
sap: {
|
||||
deploymentId: "deployment-123",
|
||||
},
|
||||
},
|
||||
}
|
||||
vi.mocked(catalog.resolveModels).mockResolvedValue({
|
||||
ok: true,
|
||||
providerId,
|
||||
configFingerprint: computeConfigFingerprint(providerId, { providerId }),
|
||||
models: new Map([["anthropic--claude-3.5-sonnet", modelInfo]]),
|
||||
defaultModelId: "anthropic--claude-3.5-sonnet",
|
||||
source: "sdk-dynamic",
|
||||
fetchedAt: 1,
|
||||
})
|
||||
const stateManager: TestStateManager = { setGlobalStateBatch: vi.fn() }
|
||||
const controller = makeController(store, catalog, stateManager)
|
||||
|
||||
await commitModelSelection(controller, {
|
||||
providerId: "sapaicore",
|
||||
mode: "plan",
|
||||
modelId: "anthropic--claude-3.5-sonnet",
|
||||
modelInfo: OpenRouterModelInfo.create({
|
||||
name: "Claude Sonnet deployment",
|
||||
contextWindow: 128_000,
|
||||
supportsPromptCache: true,
|
||||
}),
|
||||
})
|
||||
|
||||
expect(store.write).toHaveBeenCalledWith(providerId, {
|
||||
mode: "plan",
|
||||
sap: { deploymentId: "deployment-123" },
|
||||
})
|
||||
expect(store.commitSelection).toHaveBeenCalledWith(providerId, "plan", {
|
||||
providerId,
|
||||
modelId: "anthropic--claude-3.5-sonnet",
|
||||
modelInfo,
|
||||
})
|
||||
})
|
||||
|
||||
it("commitModelSelection clamps stale selections to remote model allowlists before committing", async () => {
|
||||
const { commitModelSelection } = await import("../commitModelSelection")
|
||||
const providerId = parseProviderId("openai-compatible")
|
||||
const store = makeStore({ providerId })
|
||||
const catalog = makeCatalog()
|
||||
const allowedModelInfo = {
|
||||
name: "Allowed Model",
|
||||
contextWindow: 128_000,
|
||||
supportsPromptCache: true,
|
||||
}
|
||||
vi.mocked(catalog.resolveModels).mockResolvedValue({
|
||||
ok: true,
|
||||
providerId,
|
||||
configFingerprint: computeConfigFingerprint(providerId, { providerId }),
|
||||
models: new Map([["allowed-model", allowedModelInfo]]),
|
||||
defaultModelId: "allowed-model",
|
||||
source: "host-adapter",
|
||||
fetchedAt: 1,
|
||||
})
|
||||
const stateManager: TestStateManager = {
|
||||
setGlobalStateBatch: vi.fn(),
|
||||
getRemoteConfigSettings: vi.fn(() => ({
|
||||
remoteProviderModelSettings: {
|
||||
"openai-compatible": {
|
||||
models: [{ id: "allowed-model" }],
|
||||
},
|
||||
},
|
||||
})),
|
||||
}
|
||||
const controller = makeController(store, catalog, stateManager)
|
||||
|
||||
await commitModelSelection(controller, {
|
||||
providerId: "openai-compatible",
|
||||
mode: "act",
|
||||
modelId: "blocked-model",
|
||||
modelInfo: OpenRouterModelInfo.create({
|
||||
name: "Blocked Model",
|
||||
contextWindow: 1_000,
|
||||
apiFormat: ApiFormat.OPENAI_CHAT,
|
||||
}),
|
||||
})
|
||||
|
||||
expect(store.commitSelection).toHaveBeenCalledWith(providerId, "act", {
|
||||
providerId,
|
||||
modelId: "allowed-model",
|
||||
modelInfo: allowedModelInfo,
|
||||
})
|
||||
expect(stateManager.setGlobalStateBatch).toHaveBeenCalledWith({
|
||||
planModeApiProvider: "openai",
|
||||
planModeOpenAiModelId: "allowed-model",
|
||||
actModeApiProvider: "openai",
|
||||
actModeOpenAiModelId: "allowed-model",
|
||||
})
|
||||
})
|
||||
|
||||
it("commitModelSelection reports provider changes when config is initialized", async () => {
|
||||
const { commitModelSelection } = await import("../commitModelSelection")
|
||||
const providerId = parseProviderId("deepseek")
|
||||
|
||||
@@ -96,6 +96,19 @@ describe("normalizeProviderSwitchModel", () => {
|
||||
expect(store.readSelection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("preserves unsupported VS Code providers in storage for rollback", () => {
|
||||
const providerId = parseProviderId("cline")
|
||||
const store = makeStore({ providerId })
|
||||
|
||||
const normalized = normalizeProviderSwitchModel(
|
||||
store,
|
||||
{ actModeApiProvider: "qwen-code" },
|
||||
{ actModeApiProvider: "qwen-code" },
|
||||
)
|
||||
|
||||
expect(normalized.actModeApiProvider).toBe("qwen-code")
|
||||
})
|
||||
|
||||
it("normalizes plan mode independently", () => {
|
||||
const providerId = parseProviderId("deepseek")
|
||||
const defaultModelId = MODEL_COLLECTIONS_BY_PROVIDER_ID.deepseek.provider.defaultModelId
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import type {
|
||||
EffectiveProviderConfig,
|
||||
ModelInfo,
|
||||
@@ -11,6 +11,26 @@ import { computeConfigFingerprint } from "@/sdk/model-catalog/fingerprint"
|
||||
import { parseProviderId } from "@/sdk/model-catalog/provider-id"
|
||||
import type { ProviderCatalogController } from "../providerCatalogShared"
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
let remoteConfigSettings: Record<string, unknown> = {}
|
||||
return {
|
||||
setRemoteConfigSettings(value: Record<string, unknown>): void {
|
||||
remoteConfigSettings = value
|
||||
},
|
||||
getRemoteConfigSettings(): Record<string, unknown> {
|
||||
return remoteConfigSettings
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@/core/storage/StateManager", () => ({
|
||||
StateManager: {
|
||||
get: () => ({
|
||||
getRemoteConfigSettings: mocks.getRemoteConfigSettings,
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
function fingerprint(providerId: ProviderId): ReturnType<typeof computeConfigFingerprint> {
|
||||
return computeConfigFingerprint(providerId, { providerId })
|
||||
}
|
||||
@@ -28,7 +48,6 @@ function makeStore(config: EffectiveProviderConfig): ProviderConfigStore {
|
||||
function makeCatalog(): ProviderCatalog {
|
||||
return {
|
||||
listProviders: vi.fn(async () => []),
|
||||
invalidateProviderListings: vi.fn(),
|
||||
resolveModels: vi.fn(async (providerId) => ({
|
||||
ok: true as const,
|
||||
providerId,
|
||||
@@ -63,6 +82,10 @@ function peekResult(providerId: string, entries: Array<[string, ModelInfo]>, def
|
||||
}
|
||||
|
||||
describe("resolveModelInfo", () => {
|
||||
beforeEach(() => {
|
||||
mocks.setRemoteConfigSettings({})
|
||||
})
|
||||
|
||||
it("returns committed-selection source when a matching selection exists", async () => {
|
||||
const { resolveModelInfo } = await import("../resolveModelInfo")
|
||||
const providerId = parseProviderId("deepseek")
|
||||
@@ -228,6 +251,42 @@ describe("resolveModelInfo", () => {
|
||||
expect(response.modelInfo).toBeUndefined()
|
||||
})
|
||||
|
||||
it("uses the remote allowlist default instead of stale committed custom model info", async () => {
|
||||
const { resolveModelInfo } = await import("../resolveModelInfo")
|
||||
const providerId = parseProviderId("openai")
|
||||
const store = makeStore({ providerId })
|
||||
vi.mocked(store.readSelection).mockReturnValue({
|
||||
providerId,
|
||||
modelId: "blocked-custom-model",
|
||||
modelInfo: { name: "Blocked Custom", supportsPromptCache: false, contextWindow: 1_000 },
|
||||
})
|
||||
const catalog = makeCatalog()
|
||||
vi.mocked(catalog.peekModels).mockReturnValue(
|
||||
peekResult(
|
||||
"openai",
|
||||
[["allowed-model", { name: "Allowed Model", supportsPromptCache: false, contextWindow: 128_000 }]],
|
||||
"allowed-model",
|
||||
),
|
||||
)
|
||||
mocks.setRemoteConfigSettings({
|
||||
remoteProviderModelSettings: {
|
||||
"openai-compatible": {
|
||||
models: [{ id: "allowed-model" }],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const response = await resolveModelInfo(makeController(store, catalog), {
|
||||
providerId: "openai",
|
||||
modelId: "blocked-custom-model",
|
||||
})
|
||||
|
||||
expect(response.source).toBe("sdk-default")
|
||||
expect(response.modelId).toBe("allowed-model")
|
||||
expect(response.modelInfo?.contextWindow).toBe(128_000)
|
||||
expect(store.readSelection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("still honors a custom-provider model id that does match the catalog", async () => {
|
||||
const { resolveModelInfo } = await import("../resolveModelInfo")
|
||||
const store = makeStore({ providerId: parseProviderId("openai") })
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import type { ApiConfiguration } from "@/shared/api"
|
||||
import { ensureSharedModeApiConfiguration, mirrorPlanActApiConfiguration } from "../sharedModeConfiguration"
|
||||
|
||||
describe("shared mode API configuration", () => {
|
||||
it("mirrors all plan/act fields from act mode", () => {
|
||||
expect(
|
||||
mirrorPlanActApiConfiguration({
|
||||
planModeApiProvider: "openrouter",
|
||||
actModeApiProvider: "anthropic",
|
||||
planModeApiModelId: "plan-model",
|
||||
actModeApiModelId: "act-model",
|
||||
actModeReasoningEffort: "high",
|
||||
} satisfies ApiConfiguration),
|
||||
).toMatchObject({
|
||||
planModeApiProvider: "anthropic",
|
||||
actModeApiProvider: "anthropic",
|
||||
planModeApiModelId: "act-model",
|
||||
actModeApiModelId: "act-model",
|
||||
planModeReasoningEffort: "high",
|
||||
actModeReasoningEffort: "high",
|
||||
})
|
||||
})
|
||||
|
||||
it("falls back to plan mode only when act mode is empty", () => {
|
||||
expect(
|
||||
mirrorPlanActApiConfiguration({
|
||||
planModeApiProvider: "openrouter",
|
||||
planModeApiModelId: "plan-model",
|
||||
} satisfies ApiConfiguration),
|
||||
).toMatchObject({
|
||||
planModeApiProvider: "openrouter",
|
||||
actModeApiProvider: "openrouter",
|
||||
planModeApiModelId: "plan-model",
|
||||
actModeApiModelId: "plan-model",
|
||||
})
|
||||
})
|
||||
|
||||
it("persists mirrored config and disables the legacy separate-model flag", () => {
|
||||
const setApiConfiguration = vi.fn()
|
||||
const setGlobalState = vi.fn()
|
||||
const controller = {
|
||||
stateManager: {
|
||||
getApiConfiguration: () =>
|
||||
({
|
||||
planModeApiProvider: "openrouter",
|
||||
actModeApiProvider: "anthropic",
|
||||
}) satisfies ApiConfiguration,
|
||||
getGlobalSettingsKey: ((key: "planActSeparateModelsSetting") => true) as (
|
||||
key: "planActSeparateModelsSetting",
|
||||
) => boolean,
|
||||
setApiConfiguration,
|
||||
setGlobalState,
|
||||
},
|
||||
}
|
||||
|
||||
expect(ensureSharedModeApiConfiguration(controller)).toMatchObject({
|
||||
planModeApiProvider: "anthropic",
|
||||
actModeApiProvider: "anthropic",
|
||||
})
|
||||
expect(setApiConfiguration).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
planModeApiProvider: "anthropic",
|
||||
actModeApiProvider: "anthropic",
|
||||
}),
|
||||
)
|
||||
expect(setGlobalState).toHaveBeenCalledWith("planActSeparateModelsSetting", false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ApiConfiguration, ModelsApiOptions, UpdateApiConfigurationRequestNew } from "@shared/proto/cline/models"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import type { ProviderConfigStore } from "@/sdk/model-catalog/contracts"
|
||||
import { updateApiConfiguration } from "../updateApiConfiguration"
|
||||
|
||||
function makeStore(): ProviderConfigStore {
|
||||
return {
|
||||
read: vi.fn((providerId) => ({ providerId })),
|
||||
readSelection: vi.fn(() => undefined),
|
||||
subscribe: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
write: vi.fn((providerId) => ({ providerId })),
|
||||
commitSelection: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe("updateApiConfiguration", () => {
|
||||
it("stores masked SDK provider id fields under legacy provider keys", async () => {
|
||||
const setGlobalStateBatch = vi.fn()
|
||||
const controller = {
|
||||
getProviderConfigStore: () => makeStore(),
|
||||
stateManager: {
|
||||
getApiConfiguration: vi.fn(() => ({ actModeApiProvider: "anthropic", planModeApiProvider: "anthropic" })),
|
||||
getGlobalSettingsKey: vi.fn((key: string) => (key === "planActSeparateModelsSetting" ? false : "act")),
|
||||
setGlobalStateBatch,
|
||||
},
|
||||
postStateToWebview: vi.fn(async () => undefined),
|
||||
} as any
|
||||
|
||||
await updateApiConfiguration(
|
||||
controller,
|
||||
UpdateApiConfigurationRequestNew.create({
|
||||
updates: ApiConfiguration.create({
|
||||
options: ModelsApiOptions.create({
|
||||
actModeApiProviderId: "poolside",
|
||||
}),
|
||||
}),
|
||||
updateMask: ["options.actModeApiProviderId"],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(setGlobalStateBatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actModeApiProvider: "poolside",
|
||||
planModeApiProvider: "poolside",
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,200 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import type { ProviderConfigStore } from "@/sdk/model-catalog/contracts"
|
||||
import {
|
||||
ApiProvider,
|
||||
LiteLLMModelInfo,
|
||||
ModelsApiConfiguration,
|
||||
OcaModelInfo,
|
||||
OpenAiCompatibleModelInfo,
|
||||
UpdateApiConfigurationRequest,
|
||||
} from "@/shared/proto/cline/models"
|
||||
import { updateApiConfigurationProto } from "../updateApiConfigurationProto"
|
||||
|
||||
function makeStore(): ProviderConfigStore {
|
||||
return {
|
||||
read: vi.fn((providerId) => ({ providerId })),
|
||||
readSelection: vi.fn(() => undefined),
|
||||
subscribe: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
write: vi.fn((providerId) => ({ providerId })),
|
||||
commitSelection: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe("updateApiConfigurationProto", () => {
|
||||
it("preserves SDK-only provider ids from provider_id string fields", async () => {
|
||||
const previousConfig = {
|
||||
actModeApiProvider: "anthropic",
|
||||
actModeApiModelId: "claude-sonnet-4-6",
|
||||
}
|
||||
const setApiConfiguration = vi.fn()
|
||||
const controller = {
|
||||
getProviderConfigStore: () => makeStore(),
|
||||
stateManager: {
|
||||
getApiConfiguration: vi.fn(() => previousConfig),
|
||||
setApiConfiguration,
|
||||
getGlobalSettingsKey: vi.fn(() => "act"),
|
||||
},
|
||||
handleApiConfigurationChanged: vi.fn(),
|
||||
postStateToWebview: vi.fn(async () => undefined),
|
||||
} as any
|
||||
|
||||
await updateApiConfigurationProto(
|
||||
controller,
|
||||
UpdateApiConfigurationRequest.create({
|
||||
apiConfiguration: ModelsApiConfiguration.create({
|
||||
actModeApiProviderId: "poolside",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(setApiConfiguration).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actModeApiProvider: "poolside",
|
||||
}),
|
||||
)
|
||||
expect(controller.handleApiConfigurationChanged).toHaveBeenCalledWith(
|
||||
previousConfig,
|
||||
expect.objectContaining({
|
||||
actModeApiProvider: "poolside",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("preserves representative legacy provider fields through proto conversion", async () => {
|
||||
const previousConfig = {
|
||||
planModeApiProvider: "anthropic",
|
||||
actModeApiProvider: "anthropic",
|
||||
}
|
||||
const setApiConfiguration = vi.fn()
|
||||
const controller = {
|
||||
getProviderConfigStore: () => makeStore(),
|
||||
stateManager: {
|
||||
getApiConfiguration: vi.fn(() => previousConfig),
|
||||
setApiConfiguration,
|
||||
getGlobalSettingsKey: vi.fn(() => "act"),
|
||||
},
|
||||
handleApiConfigurationChanged: vi.fn(),
|
||||
postStateToWebview: vi.fn(async () => undefined),
|
||||
} as any
|
||||
|
||||
await updateApiConfigurationProto(
|
||||
controller,
|
||||
UpdateApiConfigurationRequest.create({
|
||||
apiConfiguration: ModelsApiConfiguration.create({
|
||||
planModeApiProvider: ApiProvider.OPENAI,
|
||||
planModeApiProviderId: "openai",
|
||||
planModeOpenAiModelId: "azure-gpt-4.1",
|
||||
planModeOpenAiModelInfo: OpenAiCompatibleModelInfo.create({
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 16_384,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
isR1FormatRequired: true,
|
||||
}),
|
||||
actModeApiProvider: ApiProvider.LITELLM,
|
||||
actModeApiProviderId: "litellm",
|
||||
actModeLiteLlmModelId: "litellm-model",
|
||||
actModeLiteLlmModelInfo: LiteLLMModelInfo.create({
|
||||
contextWindow: 64_000,
|
||||
maxTokens: 8_192,
|
||||
supportsPromptCache: true,
|
||||
}),
|
||||
planModeOcaModelId: "oca-plan-model",
|
||||
planModeOcaModelInfo: OcaModelInfo.create({
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 32_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
}),
|
||||
planModeSapAiCoreModelId: "sap-plan-model",
|
||||
planModeSapAiCoreDeploymentId: "sap-plan-deployment",
|
||||
actModeSapAiCoreModelId: "sap-act-model",
|
||||
actModeSapAiCoreDeploymentId: "sap-act-deployment",
|
||||
planModeAwsBedrockCustomSelected: true,
|
||||
planModeAwsBedrockCustomModelBaseId: "plan-bedrock-base",
|
||||
actModeAwsBedrockCustomSelected: true,
|
||||
actModeAwsBedrockCustomModelBaseId: "act-bedrock-base",
|
||||
planModeReasoningEffort: "high",
|
||||
actModeReasoningEffort: "medium",
|
||||
planModeOcaReasoningEffort: "low",
|
||||
actModeOcaReasoningEffort: "high",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(setApiConfiguration).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
planModeApiProvider: "openai",
|
||||
planModeOpenAiModelId: "azure-gpt-4.1",
|
||||
planModeOpenAiModelInfo: expect.objectContaining({
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 16_384,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
isR1FormatRequired: true,
|
||||
}),
|
||||
actModeApiProvider: "litellm",
|
||||
actModeLiteLlmModelId: "litellm-model",
|
||||
actModeLiteLlmModelInfo: expect.objectContaining({
|
||||
contextWindow: 64_000,
|
||||
maxTokens: 8_192,
|
||||
supportsPromptCache: true,
|
||||
}),
|
||||
planModeOcaModelId: "oca-plan-model",
|
||||
planModeOcaModelInfo: expect.objectContaining({
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 32_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
}),
|
||||
planModeSapAiCoreModelId: "sap-plan-model",
|
||||
planModeSapAiCoreDeploymentId: "sap-plan-deployment",
|
||||
actModeSapAiCoreModelId: "sap-act-model",
|
||||
actModeSapAiCoreDeploymentId: "sap-act-deployment",
|
||||
planModeAwsBedrockCustomSelected: true,
|
||||
planModeAwsBedrockCustomModelBaseId: "plan-bedrock-base",
|
||||
actModeAwsBedrockCustomSelected: true,
|
||||
actModeAwsBedrockCustomModelBaseId: "act-bedrock-base",
|
||||
planModeReasoningEffort: "high",
|
||||
actModeReasoningEffort: "medium",
|
||||
planModeOcaReasoningEffort: "low",
|
||||
actModeOcaReasoningEffort: "high",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("preserves unsupported provider ids in storage so rollback can restore them", async () => {
|
||||
const previousConfig = {
|
||||
actModeApiProvider: "qwen-code",
|
||||
actModeApiModelId: "qwen-code-model",
|
||||
}
|
||||
const setApiConfiguration = vi.fn()
|
||||
const controller = {
|
||||
getProviderConfigStore: () => makeStore(),
|
||||
stateManager: {
|
||||
getApiConfiguration: vi.fn(() => previousConfig),
|
||||
setApiConfiguration,
|
||||
getGlobalSettingsKey: vi.fn(() => "act"),
|
||||
},
|
||||
handleApiConfigurationChanged: vi.fn(),
|
||||
postStateToWebview: vi.fn(async () => undefined),
|
||||
} as any
|
||||
|
||||
await updateApiConfigurationProto(
|
||||
controller,
|
||||
UpdateApiConfigurationRequest.create({
|
||||
apiConfiguration: ModelsApiConfiguration.create({
|
||||
actModeApiProviderId: "qwen-code",
|
||||
actModeApiModelId: "qwen-code-model",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(setApiConfiguration).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actModeApiProvider: "qwen-code",
|
||||
actModeApiModelId: "qwen-code-model",
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ModelSelection, ProviderId } from "@/sdk/model-catalog/contracts"
|
||||
import { openAiModelInfoSafeDefaults } from "@/shared/api"
|
||||
import { toLegacyApiProvider } from "@/shared/model-catalog/provider-helpers"
|
||||
import { Empty } from "@/shared/proto/cline/common"
|
||||
import { CommitModelSelectionRequest } from "@/shared/proto/cline/models"
|
||||
@@ -10,27 +12,123 @@ import {
|
||||
toModelSelection,
|
||||
} from "./providerCatalogShared"
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function readRemoteAllowedModelIds(controller: ProviderCatalogController, providerId: ProviderId): readonly string[] {
|
||||
const remoteConfigSettings = (
|
||||
controller as { stateManager?: { getRemoteConfigSettings?: () => unknown } }
|
||||
).stateManager?.getRemoteConfigSettings?.()
|
||||
if (!isRecord(remoteConfigSettings) || !isRecord(remoteConfigSettings.remoteProviderModelSettings)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const settings = remoteConfigSettings.remoteProviderModelSettings[providerId.toString()]
|
||||
if (!isRecord(settings)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const models = Array.isArray(settings.models) ? settings.models : []
|
||||
const bedrockCustomModels = Array.isArray(settings.bedrockCustomModels) ? settings.bedrockCustomModels : []
|
||||
return [
|
||||
...models.map((model) => (isRecord(model) && typeof model.id === "string" ? model.id : "")),
|
||||
...bedrockCustomModels.map((model) => (isRecord(model) && typeof model.name === "string" ? model.name : "")),
|
||||
].filter((modelId) => modelId.trim().length > 0)
|
||||
}
|
||||
|
||||
async function coerceSelectionToRemoteAllowlist(
|
||||
controller: ProviderCatalogController,
|
||||
providerId: ProviderId,
|
||||
selection: ModelSelection,
|
||||
): Promise<ModelSelection> {
|
||||
const allowedModelIds = readRemoteAllowedModelIds(controller, providerId)
|
||||
if (allowedModelIds.length === 0 || allowedModelIds.includes(selection.modelId)) {
|
||||
return selection
|
||||
}
|
||||
|
||||
const modelId = allowedModelIds[0]
|
||||
const cachedModels = controller.getProviderCatalog().peekModels(providerId)
|
||||
const resolvedModels = cachedModels?.ok ? cachedModels : await controller.getProviderCatalog().resolveModels(providerId)
|
||||
const modelInfo =
|
||||
resolvedModels.ok && resolvedModels.models.has(modelId)
|
||||
? resolvedModels.models.get(modelId)
|
||||
: { ...openAiModelInfoSafeDefaults, name: modelId }
|
||||
|
||||
return {
|
||||
providerId,
|
||||
modelId,
|
||||
modelInfo: modelInfo ?? { ...openAiModelInfoSafeDefaults, name: modelId },
|
||||
}
|
||||
}
|
||||
|
||||
async function enrichSelectionFromCatalog(
|
||||
controller: ProviderCatalogController,
|
||||
providerId: ProviderId,
|
||||
selection: ModelSelection,
|
||||
): Promise<{ selection: ModelSelection; modelWasLoaded: boolean }> {
|
||||
const cachedModels = controller.getProviderCatalog().peekModels(providerId)
|
||||
const resolvedModels = cachedModels?.ok
|
||||
? cachedModels
|
||||
: await Promise.resolve(controller.getProviderCatalog().resolveModels(providerId)).catch(() => undefined)
|
||||
if (!resolvedModels?.ok) {
|
||||
return { selection, modelWasLoaded: false }
|
||||
}
|
||||
|
||||
const modelInfo = resolvedModels.models.get(selection.modelId)
|
||||
if (!modelInfo) {
|
||||
return { selection, modelWasLoaded: false }
|
||||
}
|
||||
|
||||
return {
|
||||
selection: {
|
||||
...selection,
|
||||
modelInfo,
|
||||
},
|
||||
modelWasLoaded: true,
|
||||
}
|
||||
}
|
||||
|
||||
function readSapDeploymentId(modelInfo: ModelSelection["modelInfo"]): string | undefined {
|
||||
const metadata = (modelInfo as ModelSelection["modelInfo"] & { metadata?: Record<string, unknown> }).metadata
|
||||
const sap = metadata?.sap
|
||||
if (!sap || typeof sap !== "object" || Array.isArray(sap)) {
|
||||
return undefined
|
||||
}
|
||||
const deploymentId = (sap as Record<string, unknown>).deploymentId
|
||||
return typeof deploymentId === "string" && deploymentId.trim().length > 0 ? deploymentId.trim() : undefined
|
||||
}
|
||||
|
||||
export async function commitModelSelection(
|
||||
controller: ProviderCatalogController,
|
||||
request: CommitModelSelectionRequest,
|
||||
): Promise<Empty> {
|
||||
const providerId = parseProviderIdRequest(request.providerId)
|
||||
const mode = parseModeRequest(request.mode)
|
||||
const selection = toModelSelection(request, providerId)
|
||||
const coercedSelection = await coerceSelectionToRemoteAllowlist(controller, providerId, toModelSelection(request, providerId))
|
||||
const { selection, modelWasLoaded } = await enrichSelectionFromCatalog(controller, providerId, coercedSelection)
|
||||
const previousApiConfiguration = hasProviderCatalogStateController(controller)
|
||||
? controller.stateManager.getApiConfiguration?.()
|
||||
: undefined
|
||||
controller.getProviderConfigStore().commitSelection(providerId, mode, selection)
|
||||
const store = controller.getProviderConfigStore()
|
||||
if (providerId.toString() === "sapaicore" && modelWasLoaded) {
|
||||
store.write(providerId, { mode, sap: { deploymentId: readSapDeploymentId(selection.modelInfo) ?? "" } })
|
||||
}
|
||||
store.commitSelection(providerId, mode, selection)
|
||||
|
||||
if (hasProviderCatalogStateController(controller)) {
|
||||
const legacyProviderId = toLegacyApiProvider(providerId.toString())
|
||||
controller.stateManager.setGlobalStateBatch({
|
||||
[`${mode}ModeApiProvider`]: providerId,
|
||||
[getProviderModelIdKey(toLegacyApiProvider(providerId.toString()), mode)]: selection.modelId,
|
||||
planModeApiProvider: legacyProviderId,
|
||||
actModeApiProvider: legacyProviderId,
|
||||
[getProviderModelIdKey(legacyProviderId, "plan")]: selection.modelId,
|
||||
[getProviderModelIdKey(legacyProviderId, "act")]: selection.modelId,
|
||||
})
|
||||
const nextApiConfiguration = controller.stateManager.getApiConfiguration?.()
|
||||
if (nextApiConfiguration) {
|
||||
controller.handleApiConfigurationChanged?.(previousApiConfiguration ?? {}, nextApiConfiguration)
|
||||
}
|
||||
await controller.postStateToWebview?.()
|
||||
}
|
||||
|
||||
return Empty.create()
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
|
||||
import { fetch } from "@/shared/net"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
|
||||
/**
|
||||
* Shape of the LiteLLM `/v1/model/info` response.
|
||||
*/
|
||||
export interface LiteLlmModelInfoResponse {
|
||||
data: Array<{
|
||||
model_name: string
|
||||
litellm_params: {
|
||||
model: string
|
||||
[key: string]: any
|
||||
}
|
||||
model_info: {
|
||||
input_cost_per_token: number
|
||||
output_cost_per_token: number
|
||||
cache_creation_input_token_cost?: number
|
||||
cache_read_input_token_cost?: number
|
||||
supports_prompt_caching?: boolean
|
||||
[key: string]: any
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch LiteLLM model info from a LiteLLM proxy.
|
||||
*
|
||||
* @param baseUrl The base URL for the LiteLLM API
|
||||
* @param apiKey The API key for authentication
|
||||
* @returns The model info response or undefined if fetch fails
|
||||
*/
|
||||
export async function fetchLiteLlmModelsInfo(baseUrl: string, apiKey: string): Promise<LiteLlmModelInfoResponse | undefined> {
|
||||
// Handle base URLs that already include /v1 to avoid double /v1/v1/
|
||||
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`
|
||||
const url = `${normalizedBaseUrl}/model/info`
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"x-litellm-api-key": apiKey,
|
||||
...buildExternalBasicHeaders(),
|
||||
},
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data: LiteLlmModelInfoResponse = await response.json()
|
||||
return data
|
||||
}
|
||||
Logger.error("Failed to fetch LiteLLM model info:", response.statusText)
|
||||
// Try with Authorization header instead
|
||||
const retryResponse = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
...buildExternalBasicHeaders(),
|
||||
},
|
||||
})
|
||||
|
||||
if (retryResponse.ok) {
|
||||
const data: LiteLlmModelInfoResponse = await retryResponse.json()
|
||||
return data
|
||||
}
|
||||
Logger.error("Failed to fetch LiteLLM model info with Authorization header:", retryResponse.statusText)
|
||||
throw new Error(`Failed to fetch LiteLLM model info: ${retryResponse.statusText}`)
|
||||
} catch (error) {
|
||||
Logger.error("Error fetching LiteLLM model info:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import type { Controller } from "../index"
|
||||
export const CLINE_PASS_PROVIDER_ID = "cline-pass"
|
||||
|
||||
/**
|
||||
* ClinePass always uses the user's personal Cline account balance.
|
||||
* Cline Pass 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(undefined)
|
||||
} catch (error) {
|
||||
Logger.debug("Failed to switch ClinePass to personal account", { error })
|
||||
Logger.debug("Failed to switch Cline Pass to personal account", { error })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
CommittedModelSelection,
|
||||
GcpProviderConfig,
|
||||
OpenRouterModelInfo,
|
||||
ProviderConfigFieldOption,
|
||||
ProviderConfigField as ProviderConfigFieldProto,
|
||||
ProviderConfigResponse,
|
||||
ProviderListing as ProviderListingProto,
|
||||
ProviderModelsResponse,
|
||||
@@ -37,6 +39,7 @@ export interface ProviderCatalogStateController extends ProviderCatalogControlle
|
||||
getApiConfiguration?(): ApiConfiguration
|
||||
}
|
||||
handleApiConfigurationChanged?(previous: ApiConfiguration, next: ApiConfiguration): void
|
||||
postStateToWebview?(): Promise<void>
|
||||
}
|
||||
|
||||
export function hasProviderCatalogStateController(
|
||||
@@ -61,15 +64,59 @@ export function parseModeRequest(rawMode: string | undefined): Mode {
|
||||
throw new Error('mode must be "plan" or "act"')
|
||||
}
|
||||
|
||||
const SENSITIVE_HEADER_NAME_PATTERN = /(?:^|[-_])(authorization|cookie|api[-_]?key|token|secret|credential|session)(?:$|[-_])/i
|
||||
|
||||
function isSensitiveHeaderName(name: string): boolean {
|
||||
return SENSITIVE_HEADER_NAME_PATTERN.test(name)
|
||||
}
|
||||
|
||||
function redactHeaders(headers: Readonly<Record<string, string>> | undefined): Record<string, string> {
|
||||
if (!headers) {
|
||||
return {}
|
||||
}
|
||||
return Object.fromEntries(Object.entries(headers).map(([name, value]) => [name, isSensitiveHeaderName(name) ? "" : value]))
|
||||
}
|
||||
|
||||
export function toProviderListingProto(listing: ProviderListing): ProviderListingProto {
|
||||
const secretFieldPaths = new Set(listing.configFields?.filter((field) => field.secret).map((field) => field.path) ?? [])
|
||||
return ProviderListingProto.create({
|
||||
id: listing.id,
|
||||
name: listing.name,
|
||||
defaultModelId: listing.defaultModelId,
|
||||
family: listing.family,
|
||||
protocol: listing.protocol,
|
||||
authMethod: listing.authMethod,
|
||||
authDescription: listing.authDescription,
|
||||
baseUrlDescription: listing.baseUrlDescription,
|
||||
configFields: listing.configFields?.map((field) =>
|
||||
ProviderConfigFieldProto.create({
|
||||
path: field.path,
|
||||
label: field.label,
|
||||
type: field.type,
|
||||
placeholder: field.placeholder,
|
||||
description: field.description,
|
||||
secret: field.secret ?? false,
|
||||
required: field.required ?? false,
|
||||
options: field.options?.map((option) =>
|
||||
ProviderConfigFieldOption.create({
|
||||
label: option.label,
|
||||
value: String(option.value),
|
||||
valueJson: JSON.stringify(option.value),
|
||||
}),
|
||||
),
|
||||
defaultValueJson: field.defaultValue !== undefined ? JSON.stringify(field.defaultValue) : undefined,
|
||||
}),
|
||||
),
|
||||
configValuesJson: Object.fromEntries(
|
||||
Object.entries(listing.configValues ?? {})
|
||||
.filter(([path]) => !secretFieldPaths.has(path))
|
||||
.map(([path, value]) => [
|
||||
path,
|
||||
JSON.stringify(
|
||||
path === "headers" && typeof value === "object" ? redactHeaders(value as Record<string, string>) : value,
|
||||
),
|
||||
]),
|
||||
),
|
||||
allowsCustomModelIds: listing.allowsCustomModelIds,
|
||||
usageCostDisplay: listing.usageCostDisplay,
|
||||
})
|
||||
@@ -188,7 +235,7 @@ export function toRedactedProviderConfigResponse(
|
||||
providerId: config.providerId,
|
||||
baseUrl: config.baseUrl,
|
||||
apiLine: config.apiLine,
|
||||
headers: config.headers ?? {},
|
||||
headers: redactHeaders(config.headers),
|
||||
region: config.region,
|
||||
apiKeyLength: config.apiKey?.length ?? 0,
|
||||
hasAccessToken: Boolean(config.auth?.accessToken),
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Mode, ProviderConfigStore, ProviderId } from "@/sdk/model-catalog/
|
||||
import { parseProviderId } from "@/sdk/model-catalog/provider-id"
|
||||
import { toSdkProviderId } from "@/sdk/model-catalog/sdk-provider-id"
|
||||
import type { ApiConfiguration, ApiProvider } from "@/shared/api"
|
||||
import { isVscodeUnsupportedProvider } from "@/shared/model-catalog/provider-helpers"
|
||||
import { getProviderModelIdKey } from "@/shared/storage/provider-keys"
|
||||
|
||||
type ProviderSwitchConfig = Partial<
|
||||
@@ -67,10 +68,18 @@ export function normalizeProviderSwitchModel<T extends ProviderSwitchConfig>(
|
||||
|
||||
for (const [mode, fields] of Object.entries(modeFields) as [Mode, (typeof modeFields)[Mode]][]) {
|
||||
const previousProvider = previous[fields.provider]
|
||||
const nextProvider = (normalized[fields.provider] ?? previousProvider) as ApiProvider | undefined
|
||||
const requestedProvider = (normalized[fields.provider] ?? previousProvider) as ApiProvider | undefined
|
||||
if (!requestedProvider) {
|
||||
continue
|
||||
}
|
||||
|
||||
const nextProvider = requestedProvider
|
||||
if (!nextProvider || nextProvider === previousProvider) {
|
||||
continue
|
||||
}
|
||||
if (isVscodeUnsupportedProvider(nextProvider)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const providerId = toProviderId(nextProvider)
|
||||
if (!providerId) {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { ModelInfo } from "@shared/api"
|
||||
import { OpenRouterCompatibleModelInfo } from "@shared/proto/cline/models"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { parseProviderId } from "@/sdk/model-catalog/provider-id"
|
||||
import { toProtobufModels } from "@/shared/proto-conversions/models/typeConversion"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { ProviderCatalogController } from "./providerCatalogShared"
|
||||
import { fetchLiteLlmModelsInfo } from "./fetchLiteLlmModels"
|
||||
import { sendLiteLlmModelsEvent } from "./subscribeToLiteLlmModels"
|
||||
|
||||
/**
|
||||
@@ -12,32 +11,74 @@ import { sendLiteLlmModelsEvent } from "./subscribeToLiteLlmModels"
|
||||
* @param controller The controller instance
|
||||
* @returns Record of model ID to ModelInfo (application types)
|
||||
*/
|
||||
export async function refreshLiteLlmModels(controller: ProviderCatalogController): Promise<Record<string, ModelInfo>> {
|
||||
export async function refreshLiteLlmModels(): Promise<Record<string, ModelInfo>> {
|
||||
const models: Record<string, ModelInfo> = {}
|
||||
|
||||
const stateManager = StateManager.get()
|
||||
|
||||
try {
|
||||
const result = await controller.getProviderCatalog().resolveModels(parseProviderId("litellm"), { forceRefresh: true })
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error.message)
|
||||
// Get the LiteLLM configuration
|
||||
const apiConfiguration = stateManager.getApiConfiguration()
|
||||
const baseUrl = apiConfiguration.liteLlmBaseUrl || "http://localhost:4000"
|
||||
const apiKey = apiConfiguration.liteLlmApiKey
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error("LiteLLM API key is not configured or is invalid")
|
||||
}
|
||||
|
||||
const models: Record<string, ModelInfo> = Object.fromEntries(result.models)
|
||||
// Use the shared utility function to fetch model info
|
||||
const data = await fetchLiteLlmModelsInfo(baseUrl, apiKey)
|
||||
|
||||
// Store in StateManager's in-memory cache
|
||||
StateManager.get().setModelsCache("liteLlm", models)
|
||||
if (data?.data) {
|
||||
for (const rawModel of data.data) {
|
||||
const modelInfo: ModelInfo = {
|
||||
name: rawModel.model_name,
|
||||
maxTokens: rawModel.model_info?.max_output_tokens ?? rawModel.model_info?.max_tokens ?? 4096,
|
||||
contextWindow: rawModel.model_info?.max_input_tokens ?? rawModel.model_info?.max_tokens ?? 8192,
|
||||
supportsImages: rawModel.model_info?.supports_vision ?? false,
|
||||
supportsPromptCache: rawModel.model_info?.supports_prompt_caching ?? false,
|
||||
supportsReasoning: rawModel.model_info?.supports_reasoning ?? false,
|
||||
inputPrice: rawModel.model_info?.input_cost_per_token
|
||||
? rawModel.model_info.input_cost_per_token * 1_000_000
|
||||
: 0,
|
||||
outputPrice: rawModel.model_info?.output_cost_per_token
|
||||
? rawModel.model_info.output_cost_per_token * 1_000_000
|
||||
: 0,
|
||||
cacheWritesPrice: rawModel.model_info?.cache_creation_input_token_cost
|
||||
? rawModel.model_info.cache_creation_input_token_cost * 1_000_000
|
||||
: undefined,
|
||||
cacheReadsPrice: rawModel.model_info?.cache_read_input_token_cost
|
||||
? rawModel.model_info.cache_read_input_token_cost * 1_000_000
|
||||
: undefined,
|
||||
description: undefined,
|
||||
}
|
||||
|
||||
// Send event to subscribers
|
||||
try {
|
||||
await sendLiteLlmModelsEvent(
|
||||
OpenRouterCompatibleModelInfo.create({
|
||||
models: toProtobufModels(models),
|
||||
}),
|
||||
)
|
||||
} catch (error) {
|
||||
Logger.error("Error sending LiteLLM models event:", error)
|
||||
// Use litellm_params.model as the key since that's the actual model ID users select
|
||||
// model_name may not include the region prefix (e.g., "us." for Bedrock models)
|
||||
if (rawModel.litellm_params?.model) {
|
||||
models[rawModel.litellm_params?.model] = modelInfo
|
||||
}
|
||||
models[rawModel.model_name] = modelInfo
|
||||
}
|
||||
}
|
||||
|
||||
return models
|
||||
} catch (error) {
|
||||
Logger.error("Error fetching LiteLLM models:", error)
|
||||
throw error
|
||||
}
|
||||
|
||||
// Store in StateManager's in-memory cache
|
||||
StateManager.get().setModelsCache("liteLlm", models)
|
||||
|
||||
// Send event to subscribers
|
||||
try {
|
||||
await sendLiteLlmModelsEvent(
|
||||
OpenRouterCompatibleModelInfo.create({
|
||||
models: toProtobufModels(models),
|
||||
}),
|
||||
)
|
||||
} catch (error) {
|
||||
Logger.error("Error sending LiteLLM models event:", error)
|
||||
}
|
||||
|
||||
return models
|
||||
}
|
||||
|
||||
@@ -11,10 +11,10 @@ import { refreshLiteLlmModels } from "./refreshLiteLlmModels"
|
||||
* @returns OpenRouterCompatibleModelInfo with protobuf types
|
||||
*/
|
||||
export async function refreshLiteLlmModelsRpc(
|
||||
controller: Controller,
|
||||
_controller: Controller,
|
||||
_request: EmptyRequest,
|
||||
): Promise<OpenRouterCompatibleModelInfo> {
|
||||
const models = await refreshLiteLlmModels(controller)
|
||||
const models = await refreshLiteLlmModels()
|
||||
return OpenRouterCompatibleModelInfo.create({
|
||||
models: toProtobufModels(models),
|
||||
})
|
||||
|
||||
@@ -114,58 +114,38 @@ export async function refreshOcaModels(controller: Controller, request: StringRe
|
||||
|
||||
// Fetch current config to determine existing model selections
|
||||
const apiConfiguration = controller.stateManager.getApiConfiguration()
|
||||
const planActSeparateModelsSetting = controller.stateManager.getGlobalSettingsKey("planActSeparateModelsSetting")
|
||||
const currentMode = controller.stateManager.getGlobalSettingsKey("mode")
|
||||
|
||||
const planModeSelectedModelId: string =
|
||||
apiConfiguration?.planModeOcaModelId && models[apiConfiguration.planModeOcaModelId]
|
||||
? apiConfiguration.planModeOcaModelId
|
||||
: defaultModelId
|
||||
const actModeSelectedModelId: string =
|
||||
apiConfiguration?.actModeOcaModelId && models[apiConfiguration.actModeOcaModelId]
|
||||
? apiConfiguration.actModeOcaModelId
|
||||
: defaultModelId
|
||||
const preferredModelId =
|
||||
currentMode === "plan" ? apiConfiguration?.planModeOcaModelId : apiConfiguration?.actModeOcaModelId
|
||||
const fallbackModelId =
|
||||
currentMode === "plan" ? apiConfiguration?.actModeOcaModelId : apiConfiguration?.planModeOcaModelId
|
||||
const selectedModelId: string =
|
||||
(preferredModelId && models[preferredModelId] ? preferredModelId : undefined) ??
|
||||
(fallbackModelId && models[fallbackModelId] ? fallbackModelId : undefined) ??
|
||||
defaultModelId
|
||||
|
||||
let planModeOcaReasoningEffort: string | undefined
|
||||
let actModeOcaReasoningEffort: string | undefined
|
||||
if (
|
||||
models[planModeSelectedModelId].supportsReasoning &&
|
||||
models[planModeSelectedModelId].reasoningEffortOptions.length > 0
|
||||
) {
|
||||
planModeOcaReasoningEffort = apiConfiguration.planModeOcaReasoningEffort
|
||||
? apiConfiguration.planModeOcaReasoningEffort
|
||||
: models[planModeSelectedModelId].reasoningEffortOptions[0]
|
||||
}
|
||||
if (
|
||||
models[actModeSelectedModelId].supportsReasoning &&
|
||||
models[actModeSelectedModelId].reasoningEffortOptions.length > 0
|
||||
) {
|
||||
actModeOcaReasoningEffort = apiConfiguration.actModeOcaReasoningEffort
|
||||
? apiConfiguration.actModeOcaReasoningEffort
|
||||
: models[actModeSelectedModelId].reasoningEffortOptions[0]
|
||||
let reasoningEffort: string | undefined
|
||||
if (models[selectedModelId].supportsReasoning && models[selectedModelId].reasoningEffortOptions.length > 0) {
|
||||
const preferredReasoningEffort =
|
||||
currentMode === "plan"
|
||||
? apiConfiguration.planModeOcaReasoningEffort
|
||||
: apiConfiguration.actModeOcaReasoningEffort
|
||||
const fallbackReasoningEffort =
|
||||
currentMode === "plan"
|
||||
? apiConfiguration.actModeOcaReasoningEffort
|
||||
: apiConfiguration.planModeOcaReasoningEffort
|
||||
reasoningEffort =
|
||||
preferredReasoningEffort ?? fallbackReasoningEffort ?? models[selectedModelId].reasoningEffortOptions[0]
|
||||
}
|
||||
|
||||
// Build updates object based on plan/act mode setting
|
||||
const updates: Partial<GlobalStateAndSettings> = {}
|
||||
|
||||
if (planActSeparateModelsSetting) {
|
||||
if (currentMode === "plan") {
|
||||
updates.planModeOcaModelId = planModeSelectedModelId
|
||||
updates.planModeOcaModelInfo = models[planModeSelectedModelId]
|
||||
updates.planModeOcaReasoningEffort = planModeOcaReasoningEffort
|
||||
} else {
|
||||
updates.actModeOcaModelId = actModeSelectedModelId
|
||||
updates.actModeOcaModelInfo = models[actModeSelectedModelId]
|
||||
updates.actModeOcaReasoningEffort = actModeOcaReasoningEffort
|
||||
}
|
||||
} else {
|
||||
updates.planModeOcaModelId = planModeSelectedModelId
|
||||
updates.planModeOcaModelInfo = models[planModeSelectedModelId]
|
||||
updates.planModeOcaReasoningEffort = planModeOcaReasoningEffort
|
||||
updates.actModeOcaModelId = actModeSelectedModelId
|
||||
updates.actModeOcaModelInfo = models[actModeSelectedModelId]
|
||||
updates.actModeOcaReasoningEffort = actModeOcaReasoningEffort
|
||||
}
|
||||
updates.planModeOcaModelId = selectedModelId
|
||||
updates.planModeOcaModelInfo = models[selectedModelId]
|
||||
updates.planModeOcaReasoningEffort = reasoningEffort
|
||||
updates.actModeOcaModelId = selectedModelId
|
||||
updates.actModeOcaModelInfo = models[selectedModelId]
|
||||
updates.actModeOcaReasoningEffort = reasoningEffort
|
||||
|
||||
// Update state directly using batch method
|
||||
controller.stateManager.setGlobalStateBatch(updates)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { providerAllowsCustomModelIds, providerHasRemoteModelAllowlist } from "@/sdk/model-catalog/catalog"
|
||||
import type { ProviderModelsResult } from "@/sdk/model-catalog/contracts"
|
||||
import { providerAllowsCustomModelIds } from "@/sdk/model-catalog/custom-model-ids"
|
||||
import { ResolveModelInfoRequest, ResolveModelInfoResponse } from "@/shared/proto/cline/models"
|
||||
import { toProtobufModelInfo } from "@/shared/proto-conversions/models/typeConversion"
|
||||
import { type ProviderCatalogController, parseProviderIdRequest } from "./providerCatalogShared"
|
||||
@@ -41,7 +41,8 @@ export async function resolveModelInfo(
|
||||
const requestedModelId = request.modelId?.trim() || ""
|
||||
|
||||
const store = controller.getProviderConfigStore()
|
||||
if (requestedModelId) {
|
||||
const remoteModelAllowlistActive = providerHasRemoteModelAllowlist(providerId)
|
||||
if (requestedModelId && !remoteModelAllowlistActive) {
|
||||
const actSelection = store.readSelection(providerId, "act")
|
||||
if (actSelection?.modelId === requestedModelId) {
|
||||
return ResolveModelInfoResponse.create({
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ApiConfiguration } from "@/shared/api"
|
||||
|
||||
function isModeKey(key: string, prefix: "planMode" | "actMode"): boolean {
|
||||
return key.startsWith(prefix) && key.length > prefix.length
|
||||
}
|
||||
|
||||
function collectModeSuffixes(config: ApiConfiguration): Set<string> {
|
||||
const suffixes = new Set<string>()
|
||||
for (const key of Object.keys(config)) {
|
||||
if (isModeKey(key, "planMode")) {
|
||||
suffixes.add(key.slice("planMode".length))
|
||||
} else if (isModeKey(key, "actMode")) {
|
||||
suffixes.add(key.slice("actMode".length))
|
||||
}
|
||||
}
|
||||
return suffixes
|
||||
}
|
||||
|
||||
function chooseSharedValue(planValue: unknown, actValue: unknown): unknown {
|
||||
return actValue !== undefined ? actValue : planValue
|
||||
}
|
||||
|
||||
export function mirrorPlanActApiConfiguration(config: ApiConfiguration): ApiConfiguration {
|
||||
const next: ApiConfiguration = { ...config }
|
||||
for (const suffix of collectModeSuffixes(config)) {
|
||||
const planKey = `planMode${suffix}` as keyof ApiConfiguration
|
||||
const actKey = `actMode${suffix}` as keyof ApiConfiguration
|
||||
const sharedValue = chooseSharedValue(next[planKey], next[actKey])
|
||||
if (sharedValue === undefined) {
|
||||
continue
|
||||
}
|
||||
next[planKey] = sharedValue as never
|
||||
next[actKey] = sharedValue as never
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function apiConfigurationsEqual(left: ApiConfiguration, right: ApiConfiguration): boolean {
|
||||
const keys = new Set([...Object.keys(left), ...Object.keys(right)])
|
||||
for (const key of keys) {
|
||||
if (left[key as keyof ApiConfiguration] !== right[key as keyof ApiConfiguration]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function ensureSharedModeApiConfiguration(controller: {
|
||||
stateManager: {
|
||||
getApiConfiguration(): ApiConfiguration
|
||||
getGlobalSettingsKey(key: "planActSeparateModelsSetting"): boolean
|
||||
setApiConfiguration(config: ApiConfiguration): void
|
||||
setGlobalState(key: "planActSeparateModelsSetting", value: boolean): void
|
||||
}
|
||||
}): ApiConfiguration {
|
||||
const current = controller.stateManager.getApiConfiguration()
|
||||
const next = mirrorPlanActApiConfiguration(current)
|
||||
if (!apiConfigurationsEqual(current, next)) {
|
||||
controller.stateManager.setApiConfiguration(next)
|
||||
}
|
||||
if (controller.stateManager.getGlobalSettingsKey("planActSeparateModelsSetting") !== false) {
|
||||
controller.stateManager.setGlobalState("planActSeparateModelsSetting", false)
|
||||
}
|
||||
return next
|
||||
}
|
||||
@@ -50,6 +50,36 @@ function getAlternateModeField(fieldName: string): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
function assignMaskedOption(
|
||||
options: Partial<ApiHandlerOptions> & { planModeApiProvider?: ApiProvider; actModeApiProvider?: ApiProvider },
|
||||
key: string,
|
||||
value: unknown,
|
||||
): void {
|
||||
if (key === "planModeApiProvider") {
|
||||
options.planModeApiProvider = convertProtoToApiProvider(value as never)
|
||||
return
|
||||
}
|
||||
if (key === "actModeApiProvider") {
|
||||
options.actModeApiProvider = convertProtoToApiProvider(value as never)
|
||||
return
|
||||
}
|
||||
if (key === "planModeApiProviderId") {
|
||||
const providerId = typeof value === "string" ? value.trim() : ""
|
||||
if (providerId) {
|
||||
options.planModeApiProvider = providerId as ApiProvider
|
||||
}
|
||||
return
|
||||
}
|
||||
if (key === "actModeApiProviderId") {
|
||||
const providerId = typeof value === "string" ? value.trim() : ""
|
||||
if (providerId) {
|
||||
options.actModeApiProvider = providerId as ApiProvider
|
||||
}
|
||||
return
|
||||
}
|
||||
options[key as keyof ApiHandlerOptions] = value as never
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates API configuration using field mask
|
||||
* @param controller The controller instance
|
||||
@@ -101,33 +131,14 @@ export async function updateApiConfiguration(controller: Controller, request: Up
|
||||
}
|
||||
}
|
||||
|
||||
// Check if mode-specific configurations should be kept separate
|
||||
const separateModeConfigs = controller.stateManager.getGlobalSettingsKey("planActSeparateModelsSetting")
|
||||
|
||||
// Process entries that are in the mask
|
||||
for (const [key, value] of Object.entries(protoOptions)) {
|
||||
if (maskOptionsFields.has(key)) {
|
||||
// Handle enum conversions
|
||||
if (key === "planModeApiProvider") {
|
||||
options.planModeApiProvider = convertProtoToApiProvider(value)
|
||||
} else if (key === "actModeApiProvider") {
|
||||
options.actModeApiProvider = convertProtoToApiProvider(value)
|
||||
} else {
|
||||
options[key as keyof ApiHandlerOptions] = value
|
||||
}
|
||||
assignMaskedOption(options, key, value)
|
||||
|
||||
// If mode configs should be synced, also update the alternate mode field
|
||||
if (!separateModeConfigs) {
|
||||
const alternateField = getAlternateModeField(key)
|
||||
if (alternateField) {
|
||||
if (alternateField === "planModeApiProvider") {
|
||||
options.planModeApiProvider = convertProtoToApiProvider(value)
|
||||
} else if (alternateField === "actModeApiProvider") {
|
||||
options.actModeApiProvider = convertProtoToApiProvider(value)
|
||||
} else {
|
||||
options[alternateField as keyof ApiHandlerOptions] = value
|
||||
}
|
||||
}
|
||||
const alternateField = getAlternateModeField(key)
|
||||
if (alternateField) {
|
||||
assignMaskedOption(options, alternateField, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import { Empty } from "@shared/proto/cline/common"
|
||||
import type { UpdateApiConfigurationRequest } from "@shared/proto/cline/models"
|
||||
import { convertProtoToApiProvider } from "@shared/proto-conversions/models/api-configuration-conversion"
|
||||
import {
|
||||
fromProtobufLiteLLMModelInfo,
|
||||
fromProtobufModelInfo,
|
||||
fromProtobufOcaModelInfo,
|
||||
fromProtobufOpenAiCompatibleModelInfo,
|
||||
} from "@shared/proto-conversions/models/typeConversion"
|
||||
import { OpenaiReasoningEffort } from "@shared/storage/types"
|
||||
import { convertProtoToApiConfiguration } from "@shared/proto-conversions/models/api-configuration-conversion"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { Controller } from "../index"
|
||||
import { clearOrganizationForClinePassProviderSelection } from "./handleClinePassProviderSelection"
|
||||
import { normalizeProviderSwitchModel } from "./providerSwitchNormalization"
|
||||
import { mirrorPlanActApiConfiguration } from "./sharedModeConfiguration"
|
||||
import { createTaskApiModelShim, resolveActiveModelIdFromApiConfiguration } from "./taskApiModel"
|
||||
|
||||
/**
|
||||
@@ -30,107 +24,9 @@ export async function updateApiConfigurationProto(
|
||||
throw new Error("API configuration is required")
|
||||
}
|
||||
|
||||
const protoApiConfiguration = request.apiConfiguration
|
||||
|
||||
const convertedApiConfigurationFromProto = {
|
||||
...protoApiConfiguration,
|
||||
// Convert proto ApiProvider enums to native string types
|
||||
planModeApiProvider:
|
||||
protoApiConfiguration.planModeApiProvider !== undefined
|
||||
? convertProtoToApiProvider(protoApiConfiguration.planModeApiProvider!)
|
||||
: undefined,
|
||||
actModeApiProvider:
|
||||
protoApiConfiguration.actModeApiProvider !== undefined
|
||||
? convertProtoToApiProvider(protoApiConfiguration.actModeApiProvider!)
|
||||
: undefined,
|
||||
|
||||
// Convert ModelInfo objects (empty arrays → undefined)
|
||||
// Plan Mode
|
||||
planModeOpenRouterModelInfo: protoApiConfiguration.planModeOpenRouterModelInfo
|
||||
? fromProtobufModelInfo(protoApiConfiguration.planModeOpenRouterModelInfo)
|
||||
: undefined,
|
||||
planModeClineModelInfo: protoApiConfiguration.planModeClineModelInfo
|
||||
? fromProtobufModelInfo(protoApiConfiguration.planModeClineModelInfo)
|
||||
: undefined,
|
||||
planModeClinePassModelInfo: protoApiConfiguration.planModeClinePassModelInfo
|
||||
? fromProtobufModelInfo(protoApiConfiguration.planModeClinePassModelInfo)
|
||||
: undefined,
|
||||
planModeOpenAiModelInfo: protoApiConfiguration.planModeOpenAiModelInfo
|
||||
? fromProtobufOpenAiCompatibleModelInfo(protoApiConfiguration.planModeOpenAiModelInfo)
|
||||
: undefined,
|
||||
planModeHuggingFaceModelInfo: protoApiConfiguration.planModeHuggingFaceModelInfo
|
||||
? fromProtobufModelInfo(protoApiConfiguration.planModeHuggingFaceModelInfo)
|
||||
: undefined,
|
||||
planModeLiteLlmModelInfo: protoApiConfiguration.planModeLiteLlmModelInfo
|
||||
? fromProtobufLiteLLMModelInfo(protoApiConfiguration.planModeLiteLlmModelInfo)
|
||||
: undefined,
|
||||
planModeRequestyModelInfo: protoApiConfiguration.planModeRequestyModelInfo
|
||||
? fromProtobufModelInfo(protoApiConfiguration.planModeRequestyModelInfo)
|
||||
: undefined,
|
||||
planModeGroqModelInfo: protoApiConfiguration.planModeGroqModelInfo
|
||||
? fromProtobufModelInfo(protoApiConfiguration.planModeGroqModelInfo)
|
||||
: undefined,
|
||||
planModeHuaweiCloudMaasModelInfo: protoApiConfiguration.planModeHuaweiCloudMaasModelInfo
|
||||
? fromProtobufModelInfo(protoApiConfiguration.planModeHuaweiCloudMaasModelInfo)
|
||||
: undefined,
|
||||
planModeBasetenModelInfo: protoApiConfiguration.planModeBasetenModelInfo
|
||||
? fromProtobufModelInfo(protoApiConfiguration.planModeBasetenModelInfo)
|
||||
: undefined,
|
||||
planModeVercelAiGatewayModelInfo: protoApiConfiguration.planModeVercelAiGatewayModelInfo
|
||||
? fromProtobufModelInfo(protoApiConfiguration.planModeVercelAiGatewayModelInfo)
|
||||
: undefined,
|
||||
planModeOcaModelInfo: protoApiConfiguration.planModeOcaModelInfo
|
||||
? fromProtobufOcaModelInfo(protoApiConfiguration.planModeOcaModelInfo)
|
||||
: undefined,
|
||||
planModeAihubmixModelInfo: protoApiConfiguration.planModeAihubmixModelInfo
|
||||
? fromProtobufOpenAiCompatibleModelInfo(protoApiConfiguration.planModeAihubmixModelInfo)
|
||||
: undefined,
|
||||
|
||||
// Act Mode
|
||||
actModeOpenRouterModelInfo: protoApiConfiguration.actModeOpenRouterModelInfo
|
||||
? fromProtobufModelInfo(protoApiConfiguration.actModeOpenRouterModelInfo)
|
||||
: undefined,
|
||||
actModeClineModelInfo: protoApiConfiguration.actModeClineModelInfo
|
||||
? fromProtobufModelInfo(protoApiConfiguration.actModeClineModelInfo)
|
||||
: undefined,
|
||||
actModeClinePassModelInfo: protoApiConfiguration.actModeClinePassModelInfo
|
||||
? fromProtobufModelInfo(protoApiConfiguration.actModeClinePassModelInfo)
|
||||
: undefined,
|
||||
actModeOpenAiModelInfo: protoApiConfiguration.actModeOpenAiModelInfo
|
||||
? fromProtobufOpenAiCompatibleModelInfo(protoApiConfiguration.actModeOpenAiModelInfo)
|
||||
: undefined,
|
||||
actModeLiteLlmModelInfo: protoApiConfiguration.actModeLiteLlmModelInfo
|
||||
? fromProtobufLiteLLMModelInfo(protoApiConfiguration.actModeLiteLlmModelInfo)
|
||||
: undefined,
|
||||
actModeRequestyModelInfo: protoApiConfiguration.actModeRequestyModelInfo
|
||||
? fromProtobufModelInfo(protoApiConfiguration.actModeRequestyModelInfo)
|
||||
: undefined,
|
||||
actModeGroqModelInfo: protoApiConfiguration.actModeGroqModelInfo
|
||||
? fromProtobufModelInfo(protoApiConfiguration.actModeGroqModelInfo)
|
||||
: undefined,
|
||||
actModeHuggingFaceModelInfo: protoApiConfiguration.actModeHuggingFaceModelInfo
|
||||
? fromProtobufModelInfo(protoApiConfiguration.actModeHuggingFaceModelInfo)
|
||||
: undefined,
|
||||
actModeHuaweiCloudMaasModelInfo: protoApiConfiguration.actModeHuaweiCloudMaasModelInfo
|
||||
? fromProtobufModelInfo(protoApiConfiguration.actModeHuaweiCloudMaasModelInfo)
|
||||
: undefined,
|
||||
actModeBasetenModelInfo: protoApiConfiguration.actModeBasetenModelInfo
|
||||
? fromProtobufModelInfo(protoApiConfiguration.actModeBasetenModelInfo)
|
||||
: undefined,
|
||||
actModeVercelAiGatewayModelInfo: protoApiConfiguration.actModeVercelAiGatewayModelInfo
|
||||
? fromProtobufModelInfo(protoApiConfiguration.actModeVercelAiGatewayModelInfo)
|
||||
: undefined,
|
||||
actModeOcaModelInfo: protoApiConfiguration.actModeOcaModelInfo
|
||||
? fromProtobufOcaModelInfo(protoApiConfiguration.actModeOcaModelInfo)
|
||||
: undefined,
|
||||
actModeAihubmixModelInfo: protoApiConfiguration.actModeAihubmixModelInfo
|
||||
? fromProtobufOpenAiCompatibleModelInfo(protoApiConfiguration.actModeAihubmixModelInfo)
|
||||
: undefined,
|
||||
geminiPlanModeThinkingLevel: protoApiConfiguration.geminiPlanModeThinkingLevel,
|
||||
geminiActModeThinkingLevel: protoApiConfiguration.geminiActModeThinkingLevel,
|
||||
planModeReasoningEffort: protoApiConfiguration.planModeReasoningEffort as OpenaiReasoningEffort | undefined,
|
||||
actModeReasoningEffort: protoApiConfiguration.actModeReasoningEffort as OpenaiReasoningEffort | undefined,
|
||||
}
|
||||
const convertedApiConfigurationFromProto = mirrorPlanActApiConfiguration(
|
||||
convertProtoToApiConfiguration(request.apiConfiguration),
|
||||
)
|
||||
|
||||
const previousApiConfiguration = controller.stateManager.getApiConfiguration()
|
||||
const normalizedApiConfiguration = normalizeProviderSwitchModel(
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { ProviderConfigPatch } from "@/sdk/model-catalog/contracts"
|
||||
import { areProviderIdsEquivalent } from "@/shared/model-catalog/provider-helpers"
|
||||
import { getRemoteLockedProviderFieldPaths } from "@/shared/model-catalog/remote-config-locks"
|
||||
import { ProviderConfigResponse, WriteProviderConfigRequest } from "@/shared/proto/cline/models"
|
||||
import {
|
||||
type ProviderCatalogController,
|
||||
@@ -6,12 +9,300 @@ import {
|
||||
toRedactedProviderConfigResponse,
|
||||
} from "./providerCatalogShared"
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
function readBoolean(value: unknown): boolean | undefined {
|
||||
return typeof value === "boolean" ? value : undefined
|
||||
}
|
||||
|
||||
function readNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" ? value : undefined
|
||||
}
|
||||
|
||||
function readMode(value: unknown): ProviderConfigPatch["mode"] {
|
||||
return value === "plan" || value === "act" ? value : undefined
|
||||
}
|
||||
|
||||
function readHeaders(value: unknown): Readonly<Record<string, string>> | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined
|
||||
}
|
||||
const headers: Record<string, string> = {}
|
||||
for (const [key, headerValue] of Object.entries(value)) {
|
||||
if (typeof headerValue !== "string") {
|
||||
return undefined
|
||||
}
|
||||
headers[key] = headerValue
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
function stripInternalSettings(settings: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(Object.entries(settings).filter(([key]) => !key.startsWith("__")))
|
||||
}
|
||||
|
||||
function isSettingsObjectEmpty(settings: Record<string, unknown>): boolean {
|
||||
return Object.keys(stripInternalSettings(settings)).length === 0
|
||||
}
|
||||
|
||||
function toSettingsProviderConfigPatch(settings: Record<string, unknown>): ProviderConfigPatch {
|
||||
const mode = readMode(settings.__mode)
|
||||
const providerSettings = stripInternalSettings(settings)
|
||||
const aws = isRecord(providerSettings.aws) ? providerSettings.aws : undefined
|
||||
const gcp = isRecord(providerSettings.gcp) ? providerSettings.gcp : undefined
|
||||
const azure = isRecord(providerSettings.azure) ? providerSettings.azure : undefined
|
||||
const sap = isRecord(providerSettings.sap) ? providerSettings.sap : undefined
|
||||
const oca = isRecord(providerSettings.oca) ? providerSettings.oca : undefined
|
||||
const auth = isRecord(providerSettings.auth) ? providerSettings.auth : undefined
|
||||
const reasoning = isRecord(providerSettings.reasoning) ? providerSettings.reasoning : undefined
|
||||
|
||||
return {
|
||||
...(mode ? { mode } : {}),
|
||||
settings: providerSettings,
|
||||
...(providerSettings.apiKey !== undefined ? { apiKey: readString(providerSettings.apiKey) ?? null } : {}),
|
||||
...(providerSettings.baseUrl !== undefined ? { baseUrl: readString(providerSettings.baseUrl) ?? null } : {}),
|
||||
...(providerSettings.apiLine !== undefined ? { apiLine: readString(providerSettings.apiLine) ?? null } : {}),
|
||||
...(providerSettings.region !== undefined ? { region: readString(providerSettings.region) ?? null } : {}),
|
||||
...(providerSettings.headers !== undefined ? { headers: readHeaders(providerSettings.headers) ?? null } : {}),
|
||||
...(aws
|
||||
? {
|
||||
aws: {
|
||||
...(aws.accessKey !== undefined ? { accessKey: readString(aws.accessKey) } : {}),
|
||||
...(aws.secretKey !== undefined ? { secretKey: readString(aws.secretKey) } : {}),
|
||||
...(aws.sessionToken !== undefined ? { sessionToken: readString(aws.sessionToken) } : {}),
|
||||
...(aws.region !== undefined ? { region: readString(aws.region) } : {}),
|
||||
...(aws.authentication !== undefined ? { authentication: readString(aws.authentication) } : {}),
|
||||
...(aws.profile !== undefined ? { profile: readString(aws.profile) } : {}),
|
||||
...(aws.usePromptCache !== undefined ? { usePromptCache: readBoolean(aws.usePromptCache) } : {}),
|
||||
...(aws.endpoint !== undefined ? { endpoint: readString(aws.endpoint) } : {}),
|
||||
...(aws.customModelBaseId !== undefined ? { customModelBaseId: readString(aws.customModelBaseId) } : {}),
|
||||
...(aws.useCrossRegionInference !== undefined
|
||||
? { useCrossRegionInference: readBoolean(aws.useCrossRegionInference) }
|
||||
: {}),
|
||||
...(aws.useGlobalInference !== undefined
|
||||
? { useGlobalInference: readBoolean(aws.useGlobalInference) }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(gcp
|
||||
? {
|
||||
gcp: {
|
||||
...(gcp.projectId !== undefined ? { projectId: readString(gcp.projectId) } : {}),
|
||||
...(gcp.region !== undefined ? { region: readString(gcp.region) } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(azure
|
||||
? {
|
||||
azure: {
|
||||
...(azure.apiVersion !== undefined ? { apiVersion: readString(azure.apiVersion) } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(sap
|
||||
? {
|
||||
sap: {
|
||||
...(sap.clientId !== undefined ? { clientId: readString(sap.clientId) } : {}),
|
||||
...(sap.clientSecret !== undefined ? { clientSecret: readString(sap.clientSecret) } : {}),
|
||||
...(sap.tokenUrl !== undefined ? { tokenUrl: readString(sap.tokenUrl) } : {}),
|
||||
...(sap.resourceGroup !== undefined ? { resourceGroup: readString(sap.resourceGroup) } : {}),
|
||||
...(sap.deploymentId !== undefined ? { deploymentId: readString(sap.deploymentId) } : {}),
|
||||
...(sap.useOrchestrationMode !== undefined
|
||||
? { useOrchestrationMode: readBoolean(sap.useOrchestrationMode) }
|
||||
: {}),
|
||||
...(sap.api !== undefined ? { api: readString(sap.api) } : {}),
|
||||
...(isRecord(sap.defaultSettings) ? { defaultSettings: sap.defaultSettings } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(oca
|
||||
? {
|
||||
oca: {
|
||||
...(oca.mode !== undefined ? { mode: readString(oca.mode) } : {}),
|
||||
...(oca.usePromptCache !== undefined ? { usePromptCache: readBoolean(oca.usePromptCache) } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(auth
|
||||
? {
|
||||
auth: {
|
||||
accessToken: readString(auth.accessToken),
|
||||
refreshToken: readString(auth.refreshToken),
|
||||
accountId: readString(auth.accountId),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(reasoning
|
||||
? {
|
||||
reasoning: {
|
||||
enabled: readBoolean(reasoning.enabled),
|
||||
effort: readString(reasoning.effort),
|
||||
budgetTokens: readNumber(reasoning.budgetTokens),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(isRecord(providerSettings.extras) ? { extras: providerSettings.extras } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function deletePath(settings: Record<string, unknown>, path: string): void {
|
||||
const segments = path.split(".").filter(Boolean)
|
||||
if (segments.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const parents: Array<[Record<string, unknown>, string]> = []
|
||||
let cursor: Record<string, unknown> = settings
|
||||
for (const segment of segments.slice(0, -1)) {
|
||||
const next = cursor[segment]
|
||||
if (!isRecord(next)) {
|
||||
return
|
||||
}
|
||||
parents.push([cursor, segment])
|
||||
cursor = next
|
||||
}
|
||||
|
||||
delete cursor[segments[segments.length - 1]]
|
||||
for (const [parent, segment] of parents.reverse()) {
|
||||
const child = parent[segment]
|
||||
if (isRecord(child) && Object.keys(child).length === 0) {
|
||||
delete parent[segment]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type RemoteConfigController = ProviderCatalogController & {
|
||||
stateManager?: {
|
||||
getRemoteConfigSettings?: () => unknown
|
||||
}
|
||||
}
|
||||
|
||||
function stripLockedSettings(
|
||||
controller: ProviderCatalogController,
|
||||
providerId: string,
|
||||
settings: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const remoteConfigSettings = (controller as RemoteConfigController).stateManager?.getRemoteConfigSettings?.()
|
||||
if (!isRecord(remoteConfigSettings)) {
|
||||
return settings
|
||||
}
|
||||
|
||||
const lockedPaths = getRemoteLockedProviderFieldPaths(remoteConfigSettings, providerId)
|
||||
if (lockedPaths.size === 0) {
|
||||
return settings
|
||||
}
|
||||
|
||||
const next = structuredClone(settings) as Record<string, unknown>
|
||||
for (const path of lockedPaths) {
|
||||
deletePath(next, path)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function pickAdvertisedSettingsPaths(
|
||||
settings: Record<string, unknown>,
|
||||
allowedPaths: ReadonlySet<string>,
|
||||
parentPath = "",
|
||||
): Record<string, unknown> {
|
||||
const next: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
if (key.startsWith("__")) {
|
||||
next[key] = value
|
||||
continue
|
||||
}
|
||||
|
||||
const path = parentPath ? `${parentPath}.${key}` : key
|
||||
if (allowedPaths.has(path)) {
|
||||
next[key] = value
|
||||
continue
|
||||
}
|
||||
|
||||
if (!isRecord(value)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const hasAllowedChildPath = [...allowedPaths].some((allowedPath) => allowedPath.startsWith(`${path}.`))
|
||||
if (!hasAllowedChildPath) {
|
||||
continue
|
||||
}
|
||||
|
||||
const child = pickAdvertisedSettingsPaths(value, allowedPaths, path)
|
||||
if (Object.keys(child).length > 0) {
|
||||
next[key] = child
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
async function stripUnadvertisedSettings(
|
||||
controller: ProviderCatalogController,
|
||||
providerId: string,
|
||||
settings: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const listing = (await controller.getProviderCatalog().listProviders()).find((provider) =>
|
||||
areProviderIdsEquivalent(provider.id, providerId),
|
||||
)
|
||||
const allowedPaths = new Set(listing?.configFields?.map((field) => field.path) ?? [])
|
||||
if (allowedPaths.size === 0) {
|
||||
return Object.fromEntries(Object.entries(settings).filter(([key]) => key.startsWith("__")))
|
||||
}
|
||||
return pickAdvertisedSettingsPaths(settings, allowedPaths)
|
||||
}
|
||||
|
||||
async function providerAdvertisesFieldPath(
|
||||
controller: ProviderCatalogController,
|
||||
providerId: string,
|
||||
path: string,
|
||||
): Promise<boolean> {
|
||||
const listing = (await controller.getProviderCatalog().listProviders()).find((provider) =>
|
||||
areProviderIdsEquivalent(provider.id, providerId),
|
||||
)
|
||||
return listing?.configFields?.some((field) => field.path === path) === true
|
||||
}
|
||||
|
||||
async function stripUnsafeDirectPatch(
|
||||
controller: ProviderCatalogController,
|
||||
providerId: string,
|
||||
patch: ProviderConfigPatch,
|
||||
): Promise<ProviderConfigPatch> {
|
||||
const { auth: _auth, ...next } = patch
|
||||
if ("headers" in next && !(await providerAdvertisesFieldPath(controller, providerId, "headers"))) {
|
||||
const { headers: _headers, ...withoutHeaders } = next
|
||||
return withoutHeaders
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export async function writeProviderConfig(
|
||||
controller: ProviderCatalogController,
|
||||
request: WriteProviderConfigRequest,
|
||||
): Promise<ProviderConfigResponse> {
|
||||
const providerId = parseProviderIdRequest(request.providerId)
|
||||
const store = controller.getProviderConfigStore()
|
||||
const updated = store.write(providerId, toProviderConfigPatch(request.patch))
|
||||
const settingsJson = request.patch?.settingsJson?.trim()
|
||||
if (settingsJson) {
|
||||
const parsed = JSON.parse(settingsJson) as unknown
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("settings_json must be a JSON object")
|
||||
}
|
||||
const advertisedSettings = await stripUnadvertisedSettings(controller, providerId, parsed as Record<string, unknown>)
|
||||
const settings = stripLockedSettings(controller, providerId, advertisedSettings)
|
||||
if (isSettingsObjectEmpty(settings)) {
|
||||
return toRedactedProviderConfigResponse(store.read(providerId), store)
|
||||
}
|
||||
const updated = store.write(providerId, toSettingsProviderConfigPatch(settings))
|
||||
return toRedactedProviderConfigResponse(updated, store)
|
||||
}
|
||||
const updated = store.write(
|
||||
providerId,
|
||||
await stripUnsafeDirectPatch(controller, providerId, toProviderConfigPatch(request.patch)),
|
||||
)
|
||||
return toRedactedProviderConfigResponse(updated, store)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ModelsApiConfiguration } from "@shared/proto/cline/models"
|
||||
import { UpdateSettingsRequest } from "@shared/proto/cline/state"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import type { ProviderConfigStore } from "@/sdk/model-catalog/contracts"
|
||||
import { updateSettings } from "../updateSettings"
|
||||
|
||||
function makeStore(): ProviderConfigStore {
|
||||
return {
|
||||
read: vi.fn((providerId) => ({ providerId })),
|
||||
readSelection: vi.fn(() => undefined),
|
||||
subscribe: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
write: vi.fn((providerId) => ({ providerId })),
|
||||
commitSelection: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe("updateSettings", () => {
|
||||
it("honors SDK provider id string fields in full API configuration updates", async () => {
|
||||
const setApiConfiguration = vi.fn()
|
||||
const previousConfig = { actModeApiProvider: "anthropic" }
|
||||
const controller = {
|
||||
getProviderConfigStore: () => makeStore(),
|
||||
stateManager: {
|
||||
getApiConfiguration: vi.fn(() => previousConfig),
|
||||
getGlobalSettingsKey: vi.fn(() => "act"),
|
||||
setApiConfiguration,
|
||||
},
|
||||
handleApiConfigurationChanged: vi.fn(),
|
||||
postStateToWebview: vi.fn(async () => undefined),
|
||||
} as any
|
||||
|
||||
await updateSettings(
|
||||
controller,
|
||||
UpdateSettingsRequest.create({
|
||||
apiConfiguration: ModelsApiConfiguration.create({
|
||||
actModeApiProviderId: "poolside",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(setApiConfiguration).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actModeApiProvider: "poolside",
|
||||
}),
|
||||
)
|
||||
expect(controller.handleApiConfigurationChanged).toHaveBeenCalledWith(
|
||||
previousConfig,
|
||||
expect.objectContaining({
|
||||
actModeApiProvider: "poolside",
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -8,11 +8,18 @@ import { getHooksEnabledSafe } from "@core/hooks/hooks-utils"
|
||||
import type { ExtensionState, Platform } from "@shared/ExtensionMessage"
|
||||
import { ClineEnv } from "@/config"
|
||||
import { ExtensionRegistryInfo } from "@/registry"
|
||||
import type { ProviderConfigStore } from "@/sdk/model-catalog/contracts"
|
||||
import { parseProviderId } from "@/sdk/model-catalog/provider-id"
|
||||
import { BannerService } from "@/services/banner/BannerService"
|
||||
import { featureFlagsService } from "@/services/feature-flags"
|
||||
import { getDistinctId } from "@/services/logging/distinctId"
|
||||
import type { ApiConfiguration, ApiProvider } from "@/shared/api"
|
||||
import { toVscodeSupportedProvider } from "@/shared/model-catalog/provider-helpers"
|
||||
import { getProviderModelIdKey } from "@/shared/storage/provider-keys"
|
||||
import type { SettingsKey } from "@/shared/storage/state-keys"
|
||||
import { getLatestAnnouncementId } from "@/utils/announcements"
|
||||
import { getClineOnboardingModels } from "../models/getClineOnboardingModels"
|
||||
import { ensureSharedModeApiConfiguration } from "../models/sharedModeConfiguration"
|
||||
|
||||
/**
|
||||
* Builds the ExtensionState object to push to the webview.
|
||||
@@ -25,12 +32,13 @@ export async function getStateToPostToWebview(controller: {
|
||||
backgroundCommandRunning?: boolean
|
||||
backgroundCommandTaskId?: string
|
||||
workspaceManager?: any
|
||||
getProviderConfigStore?(): ProviderConfigStore
|
||||
}): Promise<ExtensionState> {
|
||||
const stateManager = controller.stateManager
|
||||
|
||||
// Get API configuration from cache for immediate access
|
||||
const onboardingModels = getClineOnboardingModels()
|
||||
const apiConfiguration = stateManager.getApiConfiguration()
|
||||
const apiConfiguration = hydrateApiConfigurationFromSdkSelection(controller, ensureSharedModeApiConfiguration(controller))
|
||||
const lastShownAnnouncementId = stateManager.getGlobalStateKey("lastShownAnnouncementId")
|
||||
const taskHistory = stateManager.getGlobalStateKey("taskHistory")
|
||||
const autoApprovalSettings = stateManager.getGlobalSettingsKey("autoApprovalSettings")
|
||||
@@ -44,7 +52,6 @@ export async function getStateToPostToWebview(controller: {
|
||||
const mcpMarketplaceEnabled = stateManager.getGlobalStateKey("mcpMarketplaceEnabled")
|
||||
const mcpDisplayMode = stateManager.getGlobalStateKey("mcpDisplayMode")
|
||||
const telemetrySetting = stateManager.getGlobalSettingsKey("telemetrySetting")
|
||||
const planActSeparateModelsSetting = stateManager.getGlobalSettingsKey("planActSeparateModelsSetting")
|
||||
const enableCheckpointsSetting = stateManager.getGlobalSettingsKey("enableCheckpointsSetting")
|
||||
const globalClineRulesToggles = stateManager.getGlobalStateKey("globalClineRulesToggles")
|
||||
const globalWorkflowToggles = stateManager.getGlobalStateKey("globalWorkflowToggles")
|
||||
@@ -124,7 +131,7 @@ export async function getStateToPostToWebview(controller: {
|
||||
mcpMarketplaceEnabled,
|
||||
mcpDisplayMode,
|
||||
telemetrySetting,
|
||||
planActSeparateModelsSetting,
|
||||
planActSeparateModelsSetting: false,
|
||||
enableCheckpointsSetting: enableCheckpointsSetting ?? true,
|
||||
platform,
|
||||
environment,
|
||||
@@ -188,3 +195,36 @@ export async function getStateToPostToWebview(controller: {
|
||||
openAiCodexIsAuthenticated,
|
||||
} as ExtensionState
|
||||
}
|
||||
|
||||
function hydrateApiConfigurationFromSdkSelection(
|
||||
controller: { getProviderConfigStore?(): ProviderConfigStore },
|
||||
config: ApiConfiguration,
|
||||
): ApiConfiguration {
|
||||
const store = controller.getProviderConfigStore?.()
|
||||
if (!store) {
|
||||
return config
|
||||
}
|
||||
|
||||
const next: ApiConfiguration = { ...config }
|
||||
for (const mode of ["plan", "act"] as const) {
|
||||
const provider = mode === "plan" ? next.planModeApiProvider : next.actModeApiProvider
|
||||
if (!provider) {
|
||||
continue
|
||||
}
|
||||
|
||||
const supportedProvider = toVscodeSupportedProvider(provider)
|
||||
const selection = store.readSelection(parseProviderId(supportedProvider), mode)
|
||||
if (!selection?.modelId) {
|
||||
continue
|
||||
}
|
||||
|
||||
const modelKey = getProviderModelIdKey(supportedProvider as ApiProvider, mode)
|
||||
setApiConfigurationString(next, modelKey, selection.modelId)
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
function setApiConfigurationString(config: ApiConfiguration, key: SettingsKey, value: string): void {
|
||||
;(config as Partial<Record<SettingsKey, string>>)[key] = value
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Empty } from "@shared/proto/cline/common"
|
||||
import { PlanActMode, McpDisplayMode as ProtoMcpDisplayMode, UpdateSettingsRequest } from "@shared/proto/cline/state"
|
||||
import { convertProtoToApiProvider } from "@shared/proto-conversions/models/api-configuration-conversion"
|
||||
import { OpenaiReasoningEffort } from "@shared/storage/types"
|
||||
import { convertProtoToApiConfiguration } from "@shared/proto-conversions/models/api-configuration-conversion"
|
||||
import { TelemetrySetting } from "@shared/TelemetrySetting"
|
||||
import { ClineEnv } from "@/config"
|
||||
import { fetchRemoteConfig } from "@/core/storage/remote-config/fetch"
|
||||
@@ -13,6 +12,7 @@ import { BrowserSettings as SharedBrowserSettings } from "../../../shared/Browse
|
||||
import { Controller } from ".."
|
||||
import { accountLogoutClicked } from "../account/accountLogoutClicked"
|
||||
import { normalizeProviderSwitchModel } from "../models/providerSwitchNormalization"
|
||||
import { mirrorPlanActApiConfiguration } from "../models/sharedModeConfiguration"
|
||||
import { createTaskApiModelShim, resolveActiveModelIdFromApiConfiguration } from "../models/taskApiModel"
|
||||
|
||||
/**
|
||||
@@ -31,18 +31,9 @@ export async function updateSettings(controller: Controller, request: UpdateSett
|
||||
if (request.apiConfiguration) {
|
||||
const protoApiConfiguration = request.apiConfiguration
|
||||
|
||||
const convertedApiConfigurationFromProto = {
|
||||
...protoApiConfiguration,
|
||||
// Convert proto ApiProvider enums to native string types
|
||||
planModeApiProvider: protoApiConfiguration.planModeApiProvider
|
||||
? convertProtoToApiProvider(protoApiConfiguration.planModeApiProvider)
|
||||
: undefined,
|
||||
actModeApiProvider: protoApiConfiguration.actModeApiProvider
|
||||
? convertProtoToApiProvider(protoApiConfiguration.actModeApiProvider)
|
||||
: undefined,
|
||||
planModeReasoningEffort: protoApiConfiguration.planModeReasoningEffort as OpenaiReasoningEffort | undefined,
|
||||
actModeReasoningEffort: protoApiConfiguration.actModeReasoningEffort as OpenaiReasoningEffort | undefined,
|
||||
}
|
||||
const convertedApiConfigurationFromProto = mirrorPlanActApiConfiguration(
|
||||
convertProtoToApiConfiguration(protoApiConfiguration),
|
||||
)
|
||||
|
||||
const previousApiConfiguration = controller.stateManager.getApiConfiguration()
|
||||
const normalizedApiConfiguration = normalizeProviderSwitchModel(
|
||||
@@ -66,9 +57,8 @@ export async function updateSettings(controller: Controller, request: UpdateSett
|
||||
await controller.updateTelemetrySetting(request.telemetrySetting as TelemetrySetting)
|
||||
}
|
||||
|
||||
// Update plan/act separate models setting
|
||||
if (request.planActSeparateModelsSetting !== undefined) {
|
||||
controller.stateManager.setGlobalState("planActSeparateModelsSetting", request.planActSeparateModelsSetting)
|
||||
controller.stateManager.setGlobalState("planActSeparateModelsSetting", false)
|
||||
}
|
||||
|
||||
// Update checkpoints setting
|
||||
|
||||
@@ -5,11 +5,11 @@ import { Logger } from "@/shared/services/Logger"
|
||||
import { GlobalStateAndSettings } from "@/shared/storage/state-keys"
|
||||
import type { Controller } from "../index"
|
||||
import { refreshBasetenModels } from "../models/refreshBasetenModels"
|
||||
|
||||
import { refreshGroqModels } from "../models/refreshGroqModels"
|
||||
import { refreshHicapModels } from "../models/refreshHicapModels"
|
||||
import { refreshLiteLlmModels } from "../models/refreshLiteLlmModels"
|
||||
import { refreshOpenRouterModels } from "../models/refreshOpenRouterModels"
|
||||
import { ensureSharedModeApiConfiguration } from "../models/sharedModeConfiguration"
|
||||
import { sendOpenRouterModelsEvent } from "../models/subscribeToOpenRouterModels"
|
||||
|
||||
/**
|
||||
@@ -20,6 +20,8 @@ import { sendOpenRouterModelsEvent } from "../models/subscribeToOpenRouterModels
|
||||
*/
|
||||
export async function initializeWebview(controller: Controller, _request: EmptyRequest): Promise<Empty> {
|
||||
try {
|
||||
ensureSharedModeApiConfiguration(controller)
|
||||
|
||||
// Post last cached models as soon as possible for immediate availability in the UI
|
||||
const lastCachedModels = await controller.readOpenRouterModels()
|
||||
if (lastCachedModels) {
|
||||
@@ -31,40 +33,19 @@ export async function initializeWebview(controller: Controller, _request: EmptyR
|
||||
if (models && Object.keys(models).length > 0) {
|
||||
// Update model info in state (this needs to be done here since we don't want to update state while settings is open, and we may refresh models there)
|
||||
const apiConfiguration = controller.stateManager.getApiConfiguration()
|
||||
const planActSeparateModelsSetting = controller.stateManager.getGlobalSettingsKey("planActSeparateModelsSetting")
|
||||
const currentMode = controller.stateManager.getGlobalSettingsKey("mode")
|
||||
const planModelId = apiConfiguration.planModeOpenRouterModelId
|
||||
const actModelId = apiConfiguration.actModeOpenRouterModelId
|
||||
const updates: Partial<GlobalStateAndSettings> = {}
|
||||
|
||||
if (planActSeparateModelsSetting) {
|
||||
// Separate models: update only current mode
|
||||
const modelIdField = currentMode === "plan" ? "planModeOpenRouterModelId" : "actModeOpenRouterModelId"
|
||||
const modelInfoField = currentMode === "plan" ? "planModeOpenRouterModelInfo" : "actModeOpenRouterModelInfo"
|
||||
const modelId = apiConfiguration[modelIdField]
|
||||
|
||||
if (modelId && models[modelId]) {
|
||||
controller.stateManager.setGlobalState(modelInfoField, models[modelId])
|
||||
await controller.postStateToWebview()
|
||||
}
|
||||
} else {
|
||||
// Shared models: update both plan and act modes
|
||||
const planModelId = apiConfiguration.planModeOpenRouterModelId
|
||||
const actModelId = apiConfiguration.actModeOpenRouterModelId
|
||||
const updates: Partial<GlobalStateAndSettings> = {}
|
||||
|
||||
// Update plan mode model info if we have a model ID
|
||||
if (planModelId && models[planModelId]) {
|
||||
updates.planModeOpenRouterModelInfo = models[planModelId]
|
||||
}
|
||||
|
||||
// Update act mode model info if we have a model ID
|
||||
if (actModelId && models[actModelId]) {
|
||||
updates.actModeOpenRouterModelInfo = models[actModelId]
|
||||
}
|
||||
|
||||
// Post state update if we updated any model info
|
||||
if (Object.keys(updates).length > 0) {
|
||||
controller.stateManager.setGlobalStateBatch(updates)
|
||||
await controller.postStateToWebview()
|
||||
}
|
||||
if (planModelId && models[planModelId]) {
|
||||
updates.planModeOpenRouterModelInfo = models[planModelId]
|
||||
}
|
||||
if (actModelId && models[actModelId]) {
|
||||
updates.actModeOpenRouterModelInfo = models[actModelId]
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
controller.stateManager.setGlobalStateBatch(updates)
|
||||
await controller.postStateToWebview()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -73,40 +54,19 @@ export async function initializeWebview(controller: Controller, _request: EmptyR
|
||||
if (models && Object.keys(models).length > 0) {
|
||||
// Update model info in state for Groq (this needs to be done here since we don't want to update state while settings is open, and we may refresh models there)
|
||||
const apiConfiguration = controller.stateManager.getApiConfiguration()
|
||||
const planActSeparateModelsSetting = controller.stateManager.getGlobalSettingsKey("planActSeparateModelsSetting")
|
||||
const currentMode = controller.stateManager.getGlobalSettingsKey("mode")
|
||||
const planModelId = apiConfiguration.planModeGroqModelId
|
||||
const actModelId = apiConfiguration.actModeGroqModelId
|
||||
const updates: Partial<GlobalStateAndSettings> = {}
|
||||
|
||||
if (planActSeparateModelsSetting) {
|
||||
// Separate models: update only current mode
|
||||
const modelIdField = currentMode === "plan" ? "planModeGroqModelId" : "actModeGroqModelId"
|
||||
const modelInfoField = currentMode === "plan" ? "planModeGroqModelInfo" : "actModeGroqModelInfo"
|
||||
const modelId = apiConfiguration[modelIdField]
|
||||
|
||||
if (modelId && models[modelId]) {
|
||||
controller.stateManager.setGlobalState(modelInfoField, models[modelId])
|
||||
await controller.postStateToWebview()
|
||||
}
|
||||
} else {
|
||||
// Shared models: update both plan and act modes
|
||||
const planModelId = apiConfiguration.planModeGroqModelId
|
||||
const actModelId = apiConfiguration.actModeGroqModelId
|
||||
const updates: Partial<GlobalStateAndSettings> = {}
|
||||
|
||||
// Update plan mode model info if we have a model ID
|
||||
if (planModelId && models[planModelId]) {
|
||||
updates.planModeGroqModelInfo = models[planModelId]
|
||||
}
|
||||
|
||||
// Update act mode model info if we have a model ID
|
||||
if (actModelId && models[actModelId]) {
|
||||
updates.actModeGroqModelInfo = models[actModelId]
|
||||
}
|
||||
|
||||
// Post state update if we updated any model info
|
||||
if (Object.keys(updates).length > 0) {
|
||||
controller.stateManager.setGlobalStateBatch(updates)
|
||||
await controller.postStateToWebview()
|
||||
}
|
||||
if (planModelId && models[planModelId]) {
|
||||
updates.planModeGroqModelInfo = models[planModelId]
|
||||
}
|
||||
if (actModelId && models[actModelId]) {
|
||||
updates.actModeGroqModelInfo = models[actModelId]
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
controller.stateManager.setGlobalStateBatch(updates)
|
||||
await controller.postStateToWebview()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -115,39 +75,19 @@ export async function initializeWebview(controller: Controller, _request: EmptyR
|
||||
if (models && Object.keys(models).length > 0) {
|
||||
// Update model info in state for Baseten (this needs to be done here since we don't want to update state while settings is open, and we may refresh models there)
|
||||
const apiConfiguration = controller.stateManager.getApiConfiguration()
|
||||
const planActSeparateModelsSetting = controller.stateManager.getGlobalSettingsKey("planActSeparateModelsSetting")
|
||||
const planModelId = apiConfiguration.planModeBasetenModelId
|
||||
const actModelId = apiConfiguration.actModeBasetenModelId
|
||||
const updates: Partial<GlobalStateAndSettings> = {}
|
||||
|
||||
const currentMode = controller.stateManager.getGlobalSettingsKey("mode")
|
||||
|
||||
if (planActSeparateModelsSetting) {
|
||||
// Separate models: update only current mode
|
||||
const modelIdField = currentMode === "plan" ? "planModeBasetenModelId" : "actModeBasetenModelId"
|
||||
const modelInfoField = currentMode === "plan" ? "planModeBasetenModelInfo" : "actModeBasetenModelInfo"
|
||||
const modelId = apiConfiguration[modelIdField]
|
||||
|
||||
if (modelId && models[modelId]) {
|
||||
controller.stateManager.setGlobalState(modelInfoField, models[modelId])
|
||||
await controller.postStateToWebview()
|
||||
}
|
||||
} else {
|
||||
// Shared models: update both plan and act modes
|
||||
const planModelId = apiConfiguration.planModeBasetenModelId
|
||||
const actModelId = apiConfiguration.actModeBasetenModelId
|
||||
|
||||
// Update plan mode model info if we have a model ID
|
||||
if (planModelId && models[planModelId]) {
|
||||
controller.stateManager.setGlobalState("planModeBasetenModelInfo", models[planModelId])
|
||||
}
|
||||
|
||||
// Update act mode model info if we have a model ID
|
||||
if (actModelId && models[actModelId]) {
|
||||
controller.stateManager.setGlobalState("actModeBasetenModelInfo", models[actModelId])
|
||||
}
|
||||
|
||||
// Post state update if we updated any model info
|
||||
if ((planModelId && models[planModelId]) || (actModelId && models[actModelId])) {
|
||||
await controller.postStateToWebview()
|
||||
}
|
||||
if (planModelId && models[planModelId]) {
|
||||
updates.planModeBasetenModelInfo = models[planModelId]
|
||||
}
|
||||
if (actModelId && models[actModelId]) {
|
||||
updates.actModeBasetenModelInfo = models[actModelId]
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
controller.stateManager.setGlobalStateBatch(updates)
|
||||
await controller.postStateToWebview()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -157,40 +97,19 @@ export async function initializeWebview(controller: Controller, _request: EmptyR
|
||||
if (response && response.models) {
|
||||
// Update model info in state (this needs to be done here since we don't want to update state while settings is open, and we may refresh models there)
|
||||
const apiConfiguration = controller.stateManager.getApiConfiguration()
|
||||
const planActSeparateModelsSetting = controller.stateManager.getGlobalSettingsKey("planActSeparateModelsSetting")
|
||||
const currentMode = controller.stateManager.getGlobalSettingsKey("mode")
|
||||
const planModelId = apiConfiguration.planModeHicapModelId
|
||||
const actModelId = apiConfiguration.actModeHicapModelId
|
||||
const updates: Partial<GlobalStateAndSettings> = {}
|
||||
|
||||
if (planActSeparateModelsSetting) {
|
||||
// Separate models: update only current mode
|
||||
const modelIdField = currentMode === "plan" ? "planModeHicapModelId" : "actModeHicapModelId"
|
||||
const modelInfoField = currentMode === "plan" ? "planModeHicapModelInfo" : "actModeHicapModelInfo"
|
||||
const modelId = apiConfiguration[modelIdField]
|
||||
|
||||
if (modelId && response.models[modelId]) {
|
||||
controller.stateManager.setGlobalState(modelInfoField, response.models[modelId])
|
||||
await controller.postStateToWebview()
|
||||
}
|
||||
} else {
|
||||
// Shared models: update both plan and act modes
|
||||
const planModelId = apiConfiguration.planModeHicapModelId
|
||||
const actModelId = apiConfiguration.actModeHicapModelId
|
||||
const updates: Partial<GlobalStateAndSettings> = {}
|
||||
|
||||
// Update plan mode model info if we have a model ID
|
||||
if (planModelId && response.models[planModelId]) {
|
||||
updates.planModeHicapModelInfo = response.models[planModelId]
|
||||
}
|
||||
|
||||
// Update act mode model info if we have a model ID
|
||||
if (actModelId && response.models[actModelId]) {
|
||||
updates.actModeHicapModelInfo = response.models[actModelId]
|
||||
}
|
||||
|
||||
// Post state update if we updated any model info
|
||||
if ((planModelId && response.models[planModelId]) || (actModelId && response.models[actModelId])) {
|
||||
controller.stateManager.setGlobalStateBatch(updates)
|
||||
await controller.postStateToWebview()
|
||||
}
|
||||
if (planModelId && response.models[planModelId]) {
|
||||
updates.planModeHicapModelInfo = response.models[planModelId]
|
||||
}
|
||||
if (actModelId && response.models[actModelId]) {
|
||||
updates.actModeHicapModelInfo = response.models[actModelId]
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
controller.stateManager.setGlobalStateBatch(updates)
|
||||
await controller.postStateToWebview()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -198,7 +117,7 @@ export async function initializeWebview(controller: Controller, _request: EmptyR
|
||||
const liteLlmBaseUrl = controller.stateManager.getGlobalSettingsKey("liteLlmBaseUrl")
|
||||
const liteLlmApiKey = controller.stateManager.getSecretKey("liteLlmApiKey")
|
||||
if (liteLlmBaseUrl && liteLlmApiKey) {
|
||||
await refreshLiteLlmModels(controller)
|
||||
await refreshLiteLlmModels()
|
||||
}
|
||||
|
||||
// GUI relies on model info to be up-to-date to provide the most accurate pricing, so we need to fetch the latest details on launch.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { transformRemoteConfigToStateShape } from "../utils"
|
||||
|
||||
describe("transformRemoteConfigToStateShape", () => {
|
||||
it("preserves remote provider model allowlists and Bedrock custom models", () => {
|
||||
const transformed = transformRemoteConfigToStateShape({
|
||||
version: "v1",
|
||||
providerSettings: {
|
||||
OpenAiCompatible: {
|
||||
models: [{ id: "openai-compatible-model", contextWindow: 128_000 }],
|
||||
},
|
||||
AwsBedrock: {
|
||||
models: [{ id: "anthropic.claude-sonnet-4-6", thinkingBudgetTokens: 4096 }],
|
||||
customModels: [
|
||||
{
|
||||
name: "application-inference-profile",
|
||||
baseModelId: "anthropic.claude-sonnet-4-6",
|
||||
thinkingBudgetTokens: 2048,
|
||||
},
|
||||
],
|
||||
},
|
||||
Vertex: {
|
||||
models: [{ id: "claude-sonnet-4@20250514", thinkingBudgetTokens: 1024 }],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(transformed.remoteProviderModelSettings).toEqual({
|
||||
"openai-compatible": {
|
||||
models: [{ id: "openai-compatible-model", contextWindow: 128_000 }],
|
||||
},
|
||||
bedrock: {
|
||||
models: [{ id: "anthropic.claude-sonnet-4-6", thinkingBudgetTokens: 4096 }],
|
||||
bedrockCustomModels: [
|
||||
{
|
||||
name: "application-inference-profile",
|
||||
baseModelId: "anthropic.claude-sonnet-4-6",
|
||||
thinkingBudgetTokens: 2048,
|
||||
},
|
||||
],
|
||||
},
|
||||
vertex: {
|
||||
models: [{ id: "claude-sonnet-4@20250514", thinkingBudgetTokens: 1024 }],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,12 @@
|
||||
import { synchronizeRemoteRuleToggles } from "@core/context/instructions/user-instructions/rule-helpers"
|
||||
import { parseRemoteSkillEntries } from "@core/context/instructions/user-instructions/skills"
|
||||
import type { RemoteConfig, S3AccessKeySettings } from "@shared/remote-config/schema"
|
||||
import { ConfiguredAPIKeys, GlobalStateAndSettings, RemoteConfigFields } from "@shared/storage/state-keys"
|
||||
import {
|
||||
ConfiguredAPIKeys,
|
||||
GlobalStateAndSettings,
|
||||
RemoteConfigFields,
|
||||
type RemoteProviderModelSettings,
|
||||
} from "@shared/storage/state-keys"
|
||||
import { AuthService } from "@/services/auth/AuthService"
|
||||
import { getDistinctId } from "@/services/logging/distinctId"
|
||||
import { type McpHub } from "@/services/mcp/McpHub"
|
||||
@@ -10,6 +15,7 @@ import { OpenTelemetryClientProvider } from "@/services/telemetry/providers/open
|
||||
import { OpenTelemetryTelemetryProvider } from "@/services/telemetry/providers/opentelemetry/OpenTelemetryTelemetryProvider"
|
||||
import { type TelemetryService } from "@/services/telemetry/TelemetryService"
|
||||
import { ApiProvider } from "@/shared/api"
|
||||
import { isProviderAllowedByRemoteConfig } from "@/shared/model-catalog/provider-helpers"
|
||||
import { isOpenTelemetryConfigValid, remoteConfigToOtelConfig } from "@/shared/services/config/otel-config"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { syncWorker } from "@/shared/services/worker/sync"
|
||||
@@ -43,6 +49,7 @@ function accessSettingsToBlobStorage(type: BlobStoreSettings["adapterType"], set
|
||||
*/
|
||||
export function transformRemoteConfigToStateShape(remoteConfig: RemoteConfig): Partial<RemoteConfigFields> {
|
||||
const transformed: Partial<RemoteConfigFields> = {}
|
||||
const remoteProviderModelSettings: RemoteProviderModelSettings = {}
|
||||
|
||||
// Map top-level settings
|
||||
if (remoteConfig.telemetryEnabled !== undefined) {
|
||||
@@ -142,6 +149,9 @@ export function transformRemoteConfigToStateShape(remoteConfig: RemoteConfig): P
|
||||
if (openAiSettings.azureIdentity !== undefined) {
|
||||
transformed.azureIdentity = openAiSettings.azureIdentity
|
||||
}
|
||||
if (openAiSettings.models?.length) {
|
||||
remoteProviderModelSettings["openai-compatible"] = { models: openAiSettings.models }
|
||||
}
|
||||
}
|
||||
|
||||
// Map AwsBedrock provider settings
|
||||
@@ -166,6 +176,12 @@ export function transformRemoteConfigToStateShape(remoteConfig: RemoteConfig): P
|
||||
if (awsBedrockSettings.awsBedrockEndpoint !== undefined) {
|
||||
transformed.awsBedrockEndpoint = awsBedrockSettings.awsBedrockEndpoint
|
||||
}
|
||||
if (awsBedrockSettings.models?.length || awsBedrockSettings.customModels?.length) {
|
||||
remoteProviderModelSettings.bedrock = {
|
||||
...(awsBedrockSettings.models?.length ? { models: awsBedrockSettings.models } : {}),
|
||||
...(awsBedrockSettings.customModels?.length ? { bedrockCustomModels: awsBedrockSettings.customModels } : {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const clineSettings = remoteConfig.providerSettings?.Cline
|
||||
@@ -173,6 +189,9 @@ export function transformRemoteConfigToStateShape(remoteConfig: RemoteConfig): P
|
||||
transformed.planModeApiProvider = "cline"
|
||||
transformed.actModeApiProvider = "cline"
|
||||
providers.push("cline")
|
||||
if (clineSettings.models?.length) {
|
||||
remoteProviderModelSettings.cline = { models: clineSettings.models }
|
||||
}
|
||||
}
|
||||
|
||||
// Map LiteLLM provider settings
|
||||
@@ -185,6 +204,9 @@ export function transformRemoteConfigToStateShape(remoteConfig: RemoteConfig): P
|
||||
if (liteLlmSettings.baseUrl !== undefined) {
|
||||
transformed.liteLlmBaseUrl = liteLlmSettings.baseUrl
|
||||
}
|
||||
if (liteLlmSettings.models?.length) {
|
||||
remoteProviderModelSettings.litellm = { models: liteLlmSettings.models }
|
||||
}
|
||||
}
|
||||
|
||||
// Map Vertex provider settings
|
||||
@@ -200,6 +222,9 @@ export function transformRemoteConfigToStateShape(remoteConfig: RemoteConfig): P
|
||||
if (vertexSettings.vertexRegion !== undefined) {
|
||||
transformed.vertexRegion = vertexSettings.vertexRegion
|
||||
}
|
||||
if (vertexSettings.models?.length) {
|
||||
remoteProviderModelSettings.vertex = { models: vertexSettings.models }
|
||||
}
|
||||
}
|
||||
|
||||
const anthropicSettings = remoteConfig.providerSettings?.Anthropic
|
||||
@@ -211,12 +236,18 @@ export function transformRemoteConfigToStateShape(remoteConfig: RemoteConfig): P
|
||||
if (anthropicSettings.baseUrl) {
|
||||
transformed.anthropicBaseUrl = anthropicSettings.baseUrl
|
||||
}
|
||||
if (anthropicSettings.models?.length) {
|
||||
remoteProviderModelSettings.anthropic = { models: anthropicSettings.models }
|
||||
}
|
||||
}
|
||||
|
||||
// This line needs to stay here, it is order dependent on the above code checking the configured providers
|
||||
if (providers.length > 0) {
|
||||
transformed.remoteConfiguredProviders = providers
|
||||
}
|
||||
if (Object.keys(remoteProviderModelSettings).length > 0) {
|
||||
transformed.remoteProviderModelSettings = remoteProviderModelSettings
|
||||
}
|
||||
|
||||
// Map global rules, workflows, and skills
|
||||
if (remoteConfig.globalRules !== undefined) {
|
||||
@@ -390,7 +421,7 @@ const isProviderValid = (provider?: ApiProvider, remoteConfig?: Partial<RemoteCo
|
||||
return true
|
||||
}
|
||||
|
||||
return provider && remoteConfiguredProviders.includes(provider)
|
||||
return isProviderAllowedByRemoteConfig(provider, remoteConfiguredProviders)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -94,7 +94,8 @@ async function handleComputedProperties(result: any, stateValues: Map<string, an
|
||||
result.planModeApiProvider = result.planModeApiProvider || defaultApiProvider
|
||||
result.actModeApiProvider = result.actModeApiProvider || defaultApiProvider
|
||||
|
||||
// 2. Plan/Act separate models setting with special logic
|
||||
// 2. Legacy separate-models flag. New SDK-native settings force this false,
|
||||
// but old installs may still have a stored value until normalization runs.
|
||||
const planActSeparateModelsSettingRaw = stateValues.get("planActSeparateModelsSetting")
|
||||
if (planActSeparateModelsSettingRaw === true || planActSeparateModelsSettingRaw === false) {
|
||||
result.planActSeparateModelsSetting = planActSeparateModelsSettingRaw
|
||||
|
||||
@@ -110,7 +110,7 @@ export abstract class WebviewProvider {
|
||||
font-src ${this.getCspSource()} data:;
|
||||
style-src ${this.getCspSource()} 'unsafe-inline';
|
||||
img-src ${this.getCspSource()} https: data:;
|
||||
script-src 'nonce-${nonce}' 'unsafe-eval' https://*.posthog.com https://*.cline.bot;">
|
||||
script-src 'nonce-${nonce}' 'unsafe-eval';">
|
||||
<title>Cline</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -41,6 +41,7 @@ import { ClineError } from "@/services/error/ClineError"
|
||||
import { McpHub } from "@/services/mcp/McpHub"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import type { ClineExtensionContext } from "@/shared/cline"
|
||||
import { areProviderIdsEquivalent } from "@/shared/model-catalog/provider-helpers"
|
||||
import { ShowMessageRequest, ShowMessageType } from "@/shared/proto/host/window"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { arePathsEqual, getDesktopDir } from "@/utils/path"
|
||||
@@ -506,13 +507,13 @@ export class Controller {
|
||||
return this.providerCatalog
|
||||
}
|
||||
|
||||
invalidateProviderListings(): void {
|
||||
this.providerCatalog.invalidateProviderListings()
|
||||
}
|
||||
|
||||
private handleProviderConfigChange(event: ProviderConfigChange): void {
|
||||
this.scheduleProviderConfigStatePost()
|
||||
|
||||
if (event.kind === "fields") {
|
||||
this.providerChanges.handleProviderConfigFieldsChanged(event.providerId.toString())
|
||||
}
|
||||
|
||||
if (event.kind === "selection" && this.isSelectionForActiveModeProvider(event)) {
|
||||
this.sessions
|
||||
?.updateActiveSessionModel(event.selection.modelId)
|
||||
@@ -534,7 +535,7 @@ export class Controller {
|
||||
|
||||
const apiConfig = this.stateManager.getApiConfiguration()
|
||||
const activeProvider = mode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider
|
||||
return activeProvider === event.providerId.toString()
|
||||
return areProviderIdsEquivalent(activeProvider, event.providerId.toString())
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
@@ -825,7 +826,6 @@ export class Controller {
|
||||
)
|
||||
const serializedError = clineError.serialize()
|
||||
|
||||
const failedAskTs = ts + 2
|
||||
const messages: ClineMessage[] = [
|
||||
{
|
||||
ts,
|
||||
@@ -844,7 +844,7 @@ export class Controller {
|
||||
partial: false,
|
||||
},
|
||||
{
|
||||
ts: failedAskTs,
|
||||
ts: ts + 2,
|
||||
type: "ask",
|
||||
ask: "api_req_failed",
|
||||
text: serializedError,
|
||||
@@ -852,8 +852,6 @@ export class Controller {
|
||||
},
|
||||
]
|
||||
|
||||
this.turnStateTracker.set("error", failedAskTs)
|
||||
|
||||
this.messages.appendAndEmit(messages, {
|
||||
type: "status",
|
||||
payload: {
|
||||
@@ -897,7 +895,6 @@ export class Controller {
|
||||
message: rawErrorMessage,
|
||||
})
|
||||
|
||||
const failedAskTs = ts + 1
|
||||
const messages: ClineMessage[] = [
|
||||
{
|
||||
ts,
|
||||
@@ -909,7 +906,7 @@ export class Controller {
|
||||
partial: false,
|
||||
},
|
||||
{
|
||||
ts: failedAskTs,
|
||||
ts: ts + 1,
|
||||
type: "ask",
|
||||
ask: "api_req_failed",
|
||||
text: serializedError,
|
||||
@@ -917,8 +914,6 @@ export class Controller {
|
||||
},
|
||||
]
|
||||
|
||||
this.turnStateTracker.set("error", failedAskTs)
|
||||
|
||||
this.messages.appendAndEmit(messages, {
|
||||
type: "status",
|
||||
payload: {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ApiFormat, ModelsApiConfiguration, ApiProvider as ProtoApiProvider } from "@shared/proto/cline/models"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import {
|
||||
convertApiConfigurationToProto,
|
||||
convertProtoToApiConfiguration,
|
||||
} from "@/shared/proto-conversions/models/api-configuration-conversion"
|
||||
|
||||
describe("api configuration proto conversion", () => {
|
||||
it("round-trips SDK-only provider ids through string provider fields", () => {
|
||||
const proto = convertApiConfigurationToProto({
|
||||
actModeApiProvider: "poolside" as any,
|
||||
planModeApiProvider: "openai",
|
||||
})
|
||||
|
||||
expect(proto.actModeApiProvider).toBeUndefined()
|
||||
expect(proto.actModeApiProviderId).toBe("poolside")
|
||||
expect(proto.planModeApiProvider).toBe(ProtoApiProvider.OPENAI)
|
||||
expect(proto.planModeApiProviderId).toBe("openai")
|
||||
|
||||
const config = convertProtoToApiConfiguration(proto)
|
||||
|
||||
expect(config.actModeApiProvider).toBe("poolside")
|
||||
expect(config.planModeApiProvider).toBe("openai")
|
||||
})
|
||||
|
||||
it("prefers explicit enum provider updates over stale string provider ids", () => {
|
||||
const config = convertProtoToApiConfiguration(
|
||||
ModelsApiConfiguration.create({
|
||||
actModeApiProvider: ProtoApiProvider.OPENAI,
|
||||
actModeApiProviderId: "poolside",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(config.actModeApiProvider).toBe("openai")
|
||||
})
|
||||
|
||||
it("round-trips OCA model metadata without dropping thinking or temperature", () => {
|
||||
const proto = convertApiConfigurationToProto({
|
||||
actModeApiProvider: "oca",
|
||||
actModeOcaModelId: "oca-model",
|
||||
actModeOcaModelInfo: {
|
||||
modelName: "OCA Test Model",
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 8_192,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
reasoningEffortOptions: ["low", "medium", "high"],
|
||||
thinkingConfig: {
|
||||
maxBudget: 16_384,
|
||||
outputPrice: 1.23,
|
||||
outputPriceTiers: [{ tokenLimit: 100_000, price: 2.34 }],
|
||||
},
|
||||
temperature: 0.2,
|
||||
apiFormat: ApiFormat.OPENAI_CHAT,
|
||||
},
|
||||
})
|
||||
|
||||
const config = convertProtoToApiConfiguration(proto)
|
||||
|
||||
expect(config.actModeOcaModelInfo).toMatchObject({
|
||||
thinkingConfig: {
|
||||
maxBudget: 16_384,
|
||||
outputPrice: 1.23,
|
||||
outputPriceTiers: [{ tokenLimit: 100_000, price: 2.34 }],
|
||||
},
|
||||
temperature: 0.2,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -17,7 +17,6 @@ import { AuthService, type ClineAuthInfo, LogoutReason } from "./auth-service"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockFeatureFlagsPoll = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
|
||||
const mockIdentifyAccount = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
|
||||
|
||||
// Mock StateManager
|
||||
const mockSecrets = new Map<string, string>()
|
||||
@@ -87,12 +86,6 @@ vi.mock("@/services/feature-flags", () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/services/telemetry", () => ({
|
||||
telemetryService: {
|
||||
identifyAccount: mockIdentifyAccount,
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock axios
|
||||
vi.mock("axios", () => ({
|
||||
default: {
|
||||
@@ -483,7 +476,7 @@ describe("AuthService", () => {
|
||||
describe("streaming subscriptions", () => {
|
||||
it("subscribeToAuthStatusUpdate pushes initial state immediately", async () => {
|
||||
const mockResponseStream = vi.fn()
|
||||
const mockController = { postStateToWebview: vi.fn(), invalidateProviderListings: vi.fn() }
|
||||
const mockController = { postStateToWebview: vi.fn() }
|
||||
|
||||
await authService.subscribeToAuthStatusUpdate(
|
||||
// biome-ignore lint/suspicious/noExplicitAny: mock controller for testing
|
||||
@@ -507,7 +500,7 @@ describe("AuthService", () => {
|
||||
testAccess(authService)._authenticated = true
|
||||
|
||||
const mockResponseStream = vi.fn().mockResolvedValue(undefined)
|
||||
const mockController = { postStateToWebview: vi.fn(), invalidateProviderListings: vi.fn() }
|
||||
const mockController = { postStateToWebview: vi.fn() }
|
||||
|
||||
await authService.subscribeToAuthStatusUpdate(
|
||||
// biome-ignore lint/suspicious/noExplicitAny: mock controller for testing
|
||||
@@ -518,8 +511,6 @@ describe("AuthService", () => {
|
||||
)
|
||||
|
||||
expect(mockFeatureFlagsPoll).toHaveBeenCalledWith("user-123")
|
||||
expect(mockIdentifyAccount).toHaveBeenCalledWith(authInfo.userInfo)
|
||||
expect(mockIdentifyAccount.mock.invocationCallOrder[0]).toBeLessThan(mockFeatureFlagsPoll.mock.invocationCallOrder[0])
|
||||
expect(mockController.postStateToWebview).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -527,12 +518,11 @@ describe("AuthService", () => {
|
||||
await authService.sendAuthStatusUpdate()
|
||||
|
||||
expect(mockFeatureFlagsPoll).toHaveBeenCalledWith(null)
|
||||
expect(mockIdentifyAccount).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("removes subscription on cleanup", async () => {
|
||||
const mockResponseStream = vi.fn().mockResolvedValue(undefined)
|
||||
const mockController = { postStateToWebview: vi.fn(), invalidateProviderListings: vi.fn() }
|
||||
const mockController = { postStateToWebview: vi.fn() }
|
||||
|
||||
await authService.subscribeToAuthStatusUpdate(
|
||||
// biome-ignore lint/suspicious/noExplicitAny: mock controller for testing
|
||||
|
||||
@@ -29,7 +29,6 @@ import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
|
||||
import { BannerService } from "@/services/banner/BannerService"
|
||||
import { buildBasicClineHeaders } from "@/services/EnvUtils"
|
||||
import { featureFlagsService } from "@/services/feature-flags"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { CLINE_API_ENDPOINT } from "@/shared/cline/api"
|
||||
import { fetch, getAxiosSettings } from "@/shared/net"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
@@ -945,7 +944,7 @@ export class AuthService {
|
||||
* Send an authStatusUpdate event to all active subscribers.
|
||||
*/
|
||||
async sendAuthStatusUpdate(): Promise<void> {
|
||||
const authState: AuthState = this.getInfo()
|
||||
const authInfo: AuthState = this.getInfo()
|
||||
const uniqueControllers = new Set<Controller>()
|
||||
|
||||
const streamSends = Array.from(this._activeAuthStatusUpdateHandlers).map(async (responseStream) => {
|
||||
@@ -954,7 +953,7 @@ export class AuthService {
|
||||
uniqueControllers.add(controller)
|
||||
}
|
||||
try {
|
||||
await responseStream(authState, false)
|
||||
await responseStream(authInfo, false)
|
||||
} catch (error) {
|
||||
Logger.error("[SdkAuthService] Error sending authStatusUpdate event:", error)
|
||||
this._activeAuthStatusUpdateHandlers.delete(responseStream)
|
||||
@@ -966,15 +965,7 @@ export class AuthService {
|
||||
|
||||
// Poll feature flags immediately for the current auth context so cache-only
|
||||
// consumers (for example BannerService) see the latest remote config.
|
||||
const authInfo = this._clineAuthInfo
|
||||
if (authInfo?.userInfo) {
|
||||
await telemetryService.identifyAccount(authInfo.userInfo)
|
||||
}
|
||||
const userId = authInfo?.userInfo?.id || null
|
||||
await featureFlagsService.poll(userId)
|
||||
for (const controller of uniqueControllers) {
|
||||
controller.invalidateProviderListings()
|
||||
}
|
||||
await featureFlagsService.poll(this._clineAuthInfo?.userInfo?.id || null)
|
||||
|
||||
// Update state in webviews once per unique controller
|
||||
await Promise.all(Array.from(uniqueControllers).map((c) => c.postStateToWebview()))
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
import type { ApiConfiguration } from "@shared/api"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { buildBedrockProviderConfig, buildBedrockProviderSettings, resolveBedrockAuthentication } from "./bedrock-config"
|
||||
|
||||
describe("resolveBedrockAuthentication", () => {
|
||||
it("maps the webview 'apikey' radio value straight through", () => {
|
||||
expect(resolveBedrockAuthentication({ awsAuthentication: "apikey" })).toBe("apikey")
|
||||
})
|
||||
|
||||
it("maps the webview 'credentials' radio value to the SDK 'iam' spelling", () => {
|
||||
expect(resolveBedrockAuthentication({ awsAuthentication: "credentials" })).toBe("iam")
|
||||
})
|
||||
|
||||
it("passes 'profile' through", () => {
|
||||
expect(resolveBedrockAuthentication({ awsAuthentication: "profile" })).toBe("profile")
|
||||
})
|
||||
|
||||
it("defaults to 'profile' when an AWS profile is configured but no auth is set", () => {
|
||||
expect(resolveBedrockAuthentication({ awsProfile: "dev" })).toBe("profile")
|
||||
expect(resolveBedrockAuthentication({ awsUseProfile: true })).toBe("profile")
|
||||
})
|
||||
|
||||
it("defaults to 'iam' (default credential chain) when nothing is set", () => {
|
||||
expect(resolveBedrockAuthentication({})).toBe("iam")
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildBedrockProviderConfig", () => {
|
||||
it("forwards the region and api-key authentication for a pasted Bedrock API key", () => {
|
||||
// Reproduces the reported bug: API key radio + pasted key. The key itself
|
||||
// is carried separately as the top-level ProviderConfig.apiKey; here we
|
||||
// assert the region + auth mode that were previously dropped.
|
||||
const config: ApiConfiguration = {
|
||||
awsAuthentication: "apikey",
|
||||
awsRegion: "us-east-1",
|
||||
awsBedrockApiKey: "bedrock-bearer-token",
|
||||
}
|
||||
|
||||
const result = buildBedrockProviderConfig(config, "act")
|
||||
|
||||
expect(result.region).toBe("us-east-1")
|
||||
expect(result.aws?.authentication).toBe("apikey")
|
||||
// No SigV4 credentials when authenticating with a bearer API key.
|
||||
expect(result.aws?.accessKey).toBeUndefined()
|
||||
expect(result.aws?.secretKey).toBeUndefined()
|
||||
expect(result.aws?.profile).toBeUndefined()
|
||||
})
|
||||
|
||||
it("forwards static IAM credentials", () => {
|
||||
const config: ApiConfiguration = {
|
||||
awsAuthentication: "credentials",
|
||||
awsRegion: "us-west-2",
|
||||
awsAccessKey: "AKIA...",
|
||||
awsSecretKey: "secret",
|
||||
awsSessionToken: "token",
|
||||
}
|
||||
|
||||
const result = buildBedrockProviderConfig(config, "act")
|
||||
|
||||
expect(result.region).toBe("us-west-2")
|
||||
expect(result.aws?.authentication).toBe("iam")
|
||||
expect(result.aws?.accessKey).toBe("AKIA...")
|
||||
expect(result.aws?.secretKey).toBe("secret")
|
||||
expect(result.aws?.sessionToken).toBe("token")
|
||||
})
|
||||
|
||||
it("forwards the profile only when authenticating via profile", () => {
|
||||
const profileResult = buildBedrockProviderConfig(
|
||||
{ awsAuthentication: "profile", awsProfile: "dev-profile", awsRegion: "us-east-2" },
|
||||
"act",
|
||||
)
|
||||
expect(profileResult.aws?.authentication).toBe("profile")
|
||||
expect(profileResult.aws?.profile).toBe("dev-profile")
|
||||
|
||||
// A stale profile string must not leak through when auth is api-key.
|
||||
const apiKeyResult = buildBedrockProviderConfig(
|
||||
{ awsAuthentication: "apikey", awsProfile: "dev-profile", awsRegion: "us-east-2" },
|
||||
"act",
|
||||
)
|
||||
expect(apiKeyResult.aws?.authentication).toBe("apikey")
|
||||
expect(apiKeyResult.aws?.profile).toBeUndefined()
|
||||
})
|
||||
|
||||
it("selects the mode-specific custom model base id", () => {
|
||||
const config: ApiConfiguration = {
|
||||
awsAuthentication: "apikey",
|
||||
awsRegion: "us-east-1",
|
||||
planModeAwsBedrockCustomModelBaseId: "plan-base",
|
||||
actModeAwsBedrockCustomModelBaseId: "act-base",
|
||||
}
|
||||
|
||||
expect(buildBedrockProviderConfig(config, "plan").aws?.customModelBaseId).toBe("plan-base")
|
||||
expect(buildBedrockProviderConfig(config, "act").aws?.customModelBaseId).toBe("act-base")
|
||||
})
|
||||
|
||||
it("forwards cross-region and global inference flags", () => {
|
||||
const result = buildBedrockProviderConfig(
|
||||
{
|
||||
awsAuthentication: "apikey",
|
||||
awsRegion: "us-east-1",
|
||||
awsUseCrossRegionInference: true,
|
||||
awsUseGlobalInference: true,
|
||||
},
|
||||
"act",
|
||||
)
|
||||
expect(result.useCrossRegionInference).toBe(true)
|
||||
expect(result.useGlobalInference).toBe(true)
|
||||
})
|
||||
|
||||
it("trims whitespace-only region to undefined", () => {
|
||||
const result = buildBedrockProviderConfig({ awsAuthentication: "apikey", awsRegion: " " }, "act")
|
||||
expect(result.region).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildBedrockProviderSettings (providers.json persistence)", () => {
|
||||
it("produces SDK ProviderSettings authoritative for the gateway (region + apikey)", () => {
|
||||
// This is the fix for the second bug: core builds the gateway config from
|
||||
// the providers.json `stored` entry, so the region + auth must be written
|
||||
// there (top-level region AND aws.region) to override a stale entry.
|
||||
const settings = buildBedrockProviderSettings(
|
||||
{
|
||||
awsAuthentication: "apikey",
|
||||
awsRegion: "us-east-2",
|
||||
awsBedrockApiKey: "bedrock-bearer-token",
|
||||
},
|
||||
"us.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
"act",
|
||||
)
|
||||
|
||||
expect(settings.provider).toBe("bedrock")
|
||||
expect(settings.model).toBe("us.anthropic.claude-haiku-4-5-20251001-v1:0")
|
||||
expect(settings.apiKey).toBe("bedrock-bearer-token")
|
||||
// Region must be present BOTH top-level and on aws (toProviderConfig reads
|
||||
// `settings.region ?? settings.aws?.region`).
|
||||
expect(settings.region).toBe("us-east-2")
|
||||
expect(settings.aws?.region).toBe("us-east-2")
|
||||
expect(settings.aws?.authentication).toBe("apikey")
|
||||
// No stale SigV4 credentials for api-key auth.
|
||||
expect(settings.aws?.accessKey).toBeUndefined()
|
||||
expect(settings.aws?.secretKey).toBeUndefined()
|
||||
})
|
||||
|
||||
it("omits apiKey/region when not configured", () => {
|
||||
const settings = buildBedrockProviderSettings({ awsAuthentication: "profile", awsProfile: "dev" }, "model-x", "act")
|
||||
expect(settings.apiKey).toBeUndefined()
|
||||
expect(settings.region).toBeUndefined()
|
||||
expect(settings.aws?.authentication).toBe("profile")
|
||||
expect(settings.aws?.profile).toBe("dev")
|
||||
})
|
||||
|
||||
it("does NOT persist a bearer apiKey for profile auth (keeps stored clean)", () => {
|
||||
// A stale awsBedrockApiKey must not leak into providers.json when the user
|
||||
// switched to profile auth — the SDK ignores it for SigV4, but persisting
|
||||
// it is confusing/leaky.
|
||||
const settings = buildBedrockProviderSettings(
|
||||
{
|
||||
awsAuthentication: "profile",
|
||||
awsProfile: "default",
|
||||
awsRegion: "us-east-1",
|
||||
awsBedrockApiKey: "stale-bearer-token",
|
||||
},
|
||||
"us.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
"act",
|
||||
)
|
||||
expect(settings.apiKey).toBeUndefined()
|
||||
expect(settings.aws?.authentication).toBe("profile")
|
||||
expect(settings.aws?.profile).toBe("default")
|
||||
expect(settings.aws?.region).toBe("us-east-1")
|
||||
})
|
||||
|
||||
it("does NOT persist a bearer apiKey for iam/credentials auth", () => {
|
||||
const settings = buildBedrockProviderSettings(
|
||||
{ awsAuthentication: "credentials", awsRegion: "us-east-1", awsBedrockApiKey: "stale-bearer-token" },
|
||||
"model-x",
|
||||
"act",
|
||||
)
|
||||
expect(settings.apiKey).toBeUndefined()
|
||||
expect(settings.aws?.authentication).toBe("iam")
|
||||
})
|
||||
})
|
||||
@@ -1,128 +0,0 @@
|
||||
// Maps the extension's legacy Bedrock ApiConfiguration onto the SDK's
|
||||
// structured AWS provider options (region + aws block).
|
||||
//
|
||||
// Both inference paths need this:
|
||||
// - buildSdkProviderConfig() in sdk-api-handler.ts (standalone utility calls)
|
||||
// - buildSessionConfig() in cline-session-factory.ts (main task loop, which
|
||||
// hands a CoreSessionConfig.providerConfig to ClineCore)
|
||||
//
|
||||
// Without it, the SDK gateway never receives the AWS region or authentication
|
||||
// mode, so a pasted Bedrock API key (awsBedrockApiKey + awsAuthentication
|
||||
// "apikey") is silently ignored and requests fall through to the SigV4
|
||||
// credential chain with no region. This mirrors the structured aws block built
|
||||
// by the shared provider-settings legacy migration and the CLI.
|
||||
|
||||
import type { ProviderSettings } from "@cline/core"
|
||||
import type { ProviderConfig } from "@cline/llms"
|
||||
import type { ApiConfiguration } from "@shared/api"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
|
||||
type AwsConfig = NonNullable<ProviderConfig["aws"]>
|
||||
type AwsAuthentication = NonNullable<AwsConfig["authentication"]>
|
||||
|
||||
/** The Bedrock-specific subset of an SDK ProviderConfig. */
|
||||
export type BedrockProviderConfig = Pick<ProviderConfig, "region" | "aws" | "useCrossRegionInference" | "useGlobalInference">
|
||||
|
||||
function trimToUndefined(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? trimmed : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the webview's `awsAuthentication` radio value onto the SDK's
|
||||
* `AwsConfig.authentication` spelling.
|
||||
*
|
||||
* The Bedrock settings UI stores `"apikey"`, `"profile"`, or `"credentials"`.
|
||||
* The SDK understands `"apikey"`/`"api-key"`, `"profile"`, and `"iam"`. The
|
||||
* webview's `"credentials"` option means "use the default AWS credential
|
||||
* chain", which is `"iam"` in SDK terms. When unset, fall back to the same
|
||||
* heuristic the UI uses for its default radio selection (profile when an AWS
|
||||
* profile is configured, otherwise the default credential chain).
|
||||
*/
|
||||
export function resolveBedrockAuthentication(configuration: ApiConfiguration): AwsAuthentication {
|
||||
const explicit = configuration.awsAuthentication
|
||||
if (explicit === "apikey" || explicit === "api-key" || explicit === "profile" || explicit === "iam") {
|
||||
return explicit
|
||||
}
|
||||
if (explicit === "credentials") {
|
||||
return "iam"
|
||||
}
|
||||
// No explicit selection: mirror the webview's default radio resolution.
|
||||
if (configuration.awsUseProfile || trimToUndefined(configuration.awsProfile)) {
|
||||
return "profile"
|
||||
}
|
||||
return "iam"
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Bedrock `region` + `aws` portion of the SDK ProviderConfig from the
|
||||
* extension's ApiConfiguration for the given mode (plan/act).
|
||||
*/
|
||||
export function buildBedrockProviderConfig(configuration: ApiConfiguration, mode: Mode): BedrockProviderConfig {
|
||||
const authentication = resolveBedrockAuthentication(configuration)
|
||||
const usesProfile = authentication === "profile"
|
||||
const aws: AwsConfig = {
|
||||
accessKey: trimToUndefined(configuration.awsAccessKey),
|
||||
secretKey: trimToUndefined(configuration.awsSecretKey),
|
||||
sessionToken: trimToUndefined(configuration.awsSessionToken),
|
||||
authentication,
|
||||
profile: usesProfile ? trimToUndefined(configuration.awsProfile) : undefined,
|
||||
usePromptCache: configuration.awsBedrockUsePromptCache,
|
||||
endpoint: trimToUndefined(configuration.awsBedrockEndpoint),
|
||||
customModelBaseId: trimToUndefined(
|
||||
mode === "plan"
|
||||
? configuration.planModeAwsBedrockCustomModelBaseId
|
||||
: configuration.actModeAwsBedrockCustomModelBaseId,
|
||||
),
|
||||
}
|
||||
return {
|
||||
region: trimToUndefined(configuration.awsRegion),
|
||||
aws,
|
||||
useCrossRegionInference: configuration.awsUseCrossRegionInference,
|
||||
useGlobalInference: configuration.awsUseGlobalInference,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full SDK `ProviderSettings` for Bedrock from the extension's
|
||||
* ApiConfiguration, suitable for persisting to providers.json via
|
||||
* `ProviderSettingsManager.saveProviderSettings`.
|
||||
*
|
||||
* WHY THIS EXISTS (the second Bedrock bug):
|
||||
* The main chat path runs through core's `buildProviderConfig`
|
||||
* (local-runtime-bootstrap.ts), which builds the gateway-registered
|
||||
* ProviderSettings as `{ ...stored, provider, model, apiKey, baseUrl, ... }`.
|
||||
* The `aws` block and `region` come ONLY from `stored` (providers.json) — the
|
||||
* session's `providerConfig` is consulted by a different code path and does NOT
|
||||
* override the gateway registration. So a stale providers.json Bedrock entry
|
||||
* (e.g. a legacy migration with region "us-east-1" + SigV4 keys) silently wins:
|
||||
* requests go to the wrong region and 403, even though StateManager has the
|
||||
* correct region + apikey auth.
|
||||
*
|
||||
* Writing the StateManager-derived settings back to providers.json makes
|
||||
* `stored` authoritative and correct, so the gateway is configured with the
|
||||
* region/auth the user actually selected.
|
||||
*/
|
||||
export function buildBedrockProviderSettings(configuration: ApiConfiguration, modelId: string, mode: Mode): ProviderSettings {
|
||||
const { region, aws, useCrossRegionInference, useGlobalInference } = buildBedrockProviderConfig(configuration, mode)
|
||||
// Only persist the bearer apiKey when actually authenticating with api-key.
|
||||
// Otherwise (profile/iam) a stale key could linger in providers.json; the SDK
|
||||
// ignores it for SigV4 auth, but we keep `stored` clean and unambiguous.
|
||||
const usesApiKeyAuth = aws?.authentication === "apikey" || aws?.authentication === "api-key"
|
||||
const apiKey = usesApiKeyAuth ? trimToUndefined(configuration.awsBedrockApiKey) : undefined
|
||||
return {
|
||||
provider: "bedrock",
|
||||
model: modelId,
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
...(region ? { region } : {}),
|
||||
aws: {
|
||||
...aws,
|
||||
...(region ? { region } : {}),
|
||||
...(useCrossRegionInference !== undefined ? { useCrossRegionInference } : {}),
|
||||
...(useGlobalInference !== undefined ? { useGlobalInference } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// Creates and manages SDK sessions using ClineCore. This factory handles:
|
||||
// - Creating ClineCore instances with proper configuration
|
||||
// - Building session config from legacy state (provider, model, API key)
|
||||
// - Building session config from SDK provider settings plus VS Code host state
|
||||
// - Custom session persistence adapter reading ~/.cline/data/tasks/
|
||||
// - Mapping HistoryItem ↔ SDK session fields
|
||||
//
|
||||
@@ -21,22 +21,26 @@ import { buildClineSystemPrompt } from "@cline/shared"
|
||||
import type { ApiConfiguration } from "@shared/api"
|
||||
import type { HistoryItem } from "@shared/HistoryItem"
|
||||
import { DEFAULT_LANGUAGE_SETTINGS, getLanguageKey, type LanguageDisplay } from "@shared/Languages"
|
||||
import {
|
||||
isVscodeUnsupportedProvider,
|
||||
toVscodeSupportedProvider,
|
||||
VSCODE_DEFAULT_PROVIDER_ID,
|
||||
} from "@shared/model-catalog/provider-helpers"
|
||||
import { Logger } from "@shared/services/Logger"
|
||||
import type { Settings } from "@shared/storage/state-keys"
|
||||
import type { RemoteProviderModelSettings, Settings } from "@shared/storage/state-keys"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import { stringifyVsCodeLmModelSelector } from "@shared/vsCodeSelectorUtils"
|
||||
import { mirrorPlanActApiConfiguration } from "@/core/controller/models/sharedModeConfiguration"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { ExtensionRegistryInfo } from "@/registry"
|
||||
import { getFeatureFlagsService } from "@/services/feature-flags"
|
||||
import { getDistinctId } from "@/services/logging/distinctId"
|
||||
import { fetch } from "@/shared/net"
|
||||
import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
|
||||
import { type BedrockProviderConfig, buildBedrockProviderConfig } from "./bedrock-config"
|
||||
import { buildAgentHooks } from "./hooks-adapter"
|
||||
import { readTaskHistory, resolveDataDir } from "./legacy-state-reader"
|
||||
import { buildEffectiveProviderConfig, buildRemoteProviderConfig } from "./model-catalog/effective-config"
|
||||
import { parseProviderId } from "./model-catalog/provider-id"
|
||||
import { toSdkProviderId } from "./model-catalog/sdk-provider-id"
|
||||
import { getProviderSettingsManager } from "./provider-migration"
|
||||
import { buildSapProviderConfig, type SapProviderConfig } from "./sap-config"
|
||||
import type { SdkSessionHost } from "./session-host"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -124,6 +128,7 @@ function resolveWorkspaceName(workspacePath: string): string {
|
||||
type ReasoningEffort = NonNullable<CoreSessionConfig["reasoningEffort"]>
|
||||
type ProviderReasoningSettings = NonNullable<ProviderSettings["reasoning"]>
|
||||
type SessionReasoningConfig = Pick<CoreSessionConfig, "thinking" | "reasoningEffort">
|
||||
type RuntimeProviderConfig = NonNullable<CoreSessionConfig["providerConfig"]>
|
||||
|
||||
function isReasoningEffort(value: unknown): value is ReasoningEffort {
|
||||
return value === "low" || value === "medium" || value === "high" || value === "xhigh"
|
||||
@@ -133,6 +138,24 @@ function hasStaleDisabledReasoningFields(reasoning: ProviderReasoningSettings |
|
||||
return reasoning?.enabled === false && (reasoning.effort !== undefined || reasoning.budgetTokens !== undefined)
|
||||
}
|
||||
|
||||
function resolvePersistedProviderConfig(
|
||||
providerId: string,
|
||||
dataDir: string = resolveDataDir(),
|
||||
): RuntimeProviderConfig | undefined {
|
||||
const manager = getProviderSettingsManager(dataDir)
|
||||
const sdkProviderId = toSdkProviderId(providerId)
|
||||
return (
|
||||
manager.getProviderConfig(sdkProviderId, { includeKnownModels: false }) ??
|
||||
manager.getProviderConfig(providerId, { includeKnownModels: false })
|
||||
)
|
||||
}
|
||||
|
||||
function resolvePersistedProviderSettings(providerId: string, dataDir: string = resolveDataDir()): ProviderSettings | undefined {
|
||||
const manager = getProviderSettingsManager(dataDir)
|
||||
const sdkProviderId = toSdkProviderId(providerId)
|
||||
return manager.getProviderSettings(sdkProviderId) ?? manager.getProviderSettings(providerId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert SDK provider-level reasoning settings into the SDK session fields that
|
||||
* are actually forwarded as model options. Keep `thinking` and
|
||||
@@ -157,12 +180,42 @@ export function normalizeProviderReasoningSettings(reasoning: ProviderReasoningS
|
||||
return isReasoningEffort(reasoning.effort) ? { reasoningEffort: reasoning.effort } : {}
|
||||
}
|
||||
|
||||
function resolveProviderReasoningConfig(providerId: string): SessionReasoningConfig {
|
||||
function hasSessionReasoningConfig(config: SessionReasoningConfig): boolean {
|
||||
return config.thinking !== undefined || config.reasoningEffort !== undefined
|
||||
}
|
||||
|
||||
function normalizeLegacyReasoningEffort(value: unknown): SessionReasoningConfig {
|
||||
if (value === "none") {
|
||||
return { thinking: false }
|
||||
}
|
||||
if (isReasoningEffort(value)) {
|
||||
return { thinking: true, reasoningEffort: value }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function resolveLegacyReasoningConfig(providerId: string, mode: Mode, apiConfig: ApiConfiguration): SessionReasoningConfig {
|
||||
const sdkProviderId = toSdkProviderId(providerId)
|
||||
if (sdkProviderId === "openai-codex") {
|
||||
return normalizeLegacyReasoningEffort(
|
||||
mode === "plan" ? apiConfig.planModeReasoningEffort : apiConfig.actModeReasoningEffort,
|
||||
)
|
||||
}
|
||||
if (sdkProviderId === "oca") {
|
||||
return normalizeLegacyReasoningEffort(
|
||||
mode === "plan" ? apiConfig.planModeOcaReasoningEffort : apiConfig.actModeOcaReasoningEffort,
|
||||
)
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function resolveProviderReasoningConfig(providerId: string, mode: Mode, apiConfig: ApiConfiguration): SessionReasoningConfig {
|
||||
try {
|
||||
const manager = getProviderSettingsManager(resolveDataDir())
|
||||
const settings = manager.getProviderSettings(providerId)
|
||||
const sdkProviderId = toSdkProviderId(providerId)
|
||||
const settings = manager.getProviderSettings(sdkProviderId) ?? manager.getProviderSettings(providerId)
|
||||
if (!settings) {
|
||||
return {}
|
||||
return resolveLegacyReasoningConfig(providerId, mode, apiConfig)
|
||||
}
|
||||
|
||||
if (hasStaleDisabledReasoningFields(settings.reasoning)) {
|
||||
@@ -175,33 +228,16 @@ function resolveProviderReasoningConfig(providerId: string): SessionReasoningCon
|
||||
return normalizeProviderReasoningSettings(sanitizedSettings.reasoning)
|
||||
}
|
||||
|
||||
return normalizeProviderReasoningSettings(settings.reasoning)
|
||||
const providerReasoningConfig = normalizeProviderReasoningSettings(settings.reasoning)
|
||||
return hasSessionReasoningConfig(providerReasoningConfig)
|
||||
? providerReasoningConfig
|
||||
: resolveLegacyReasoningConfig(providerId, mode, apiConfig)
|
||||
} catch (error) {
|
||||
Logger.warn("[SessionFactory] Provider reasoning resolution failed:", error)
|
||||
return {}
|
||||
return resolveLegacyReasoningConfig(providerId, mode, apiConfig)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOcaReasoningConfig(mode: Mode, apiConfig: ApiConfiguration | undefined): SessionReasoningConfig | undefined {
|
||||
const rawEffort = mode === "plan" ? apiConfig?.planModeOcaReasoningEffort : apiConfig?.actModeOcaReasoningEffort
|
||||
const effort = rawEffort?.trim().toLowerCase()
|
||||
if (!effort) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (effort === "none") {
|
||||
return { thinking: false }
|
||||
}
|
||||
|
||||
return isReasoningEffort(effort) ? { thinking: true, reasoningEffort: effort } : undefined
|
||||
}
|
||||
|
||||
function resolveOpenAiCompatibleMaxTokens(config: ApiConfiguration | undefined, mode: Mode): number | undefined {
|
||||
const modelInfo = mode === "plan" ? config?.planModeOpenAiModelInfo : config?.actModeOpenAiModelInfo
|
||||
const maxTokens = modelInfo?.maxTokens
|
||||
return typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0 ? maxTokens : undefined
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider → API key field mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -247,6 +283,7 @@ const PROVIDER_API_KEY_MAP: Record<string, keyof ApiConfiguration> = {
|
||||
aihubmix: "aihubmixApiKey",
|
||||
nousResearch: "nousResearchApiKey",
|
||||
"vercel-ai-gateway": "vercelAiGatewayApiKey",
|
||||
sapaicore: "sapAiCoreClientId", // SAP uses client ID + secret
|
||||
claude_code: "apiKey", // Claude Code uses anthropic key
|
||||
wandb: "wandbApiKey",
|
||||
"qwen-code": "qwenApiKey",
|
||||
@@ -284,13 +321,14 @@ const PROVIDER_MODEL_ID_MAP: Record<string, { plan: keyof ApiConfiguration; act:
|
||||
hicap: { plan: "planModeHicapModelId", act: "actModeHicapModelId" },
|
||||
nousResearch: { plan: "planModeNousResearchModelId", act: "actModeNousResearchModelId" },
|
||||
"vercel-ai-gateway": { plan: "planModeVercelAiGatewayModelId", act: "actModeVercelAiGatewayModelId" },
|
||||
sapaicore: { plan: "planModeSapAiCoreModelId", act: "actModeSapAiCoreModelId" },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider/model defaults
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DEFAULT_PROVIDER_ID = "cline"
|
||||
const DEFAULT_PROVIDER_ID = VSCODE_DEFAULT_PROVIDER_ID
|
||||
|
||||
export function getDefaultModelIdForProvider(providerId: string): string | undefined {
|
||||
const sdkProviderId = toSdkProviderId(providerId)
|
||||
@@ -353,20 +391,6 @@ export function resolveApiKey(providerId: string, config: ApiConfiguration): str
|
||||
}
|
||||
}
|
||||
|
||||
// SDK-backed API-key providers save credentials in providers.json instead
|
||||
// of legacy ApiConfiguration fields. Fall back to that store so providers
|
||||
// exposed through the SDK settings UI still receive credentials at task
|
||||
// startup.
|
||||
try {
|
||||
const manager = getProviderSettingsManager()
|
||||
const apiKey = resolveProviderApiKeyFromSettings(manager, providerId)?.trim()
|
||||
if (apiKey) {
|
||||
return apiKey
|
||||
}
|
||||
} catch {
|
||||
Logger.warn(`[SessionFactory] Failed to read ${providerId} API key from providers.json`)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -374,7 +398,7 @@ export function resolveApiKey(providerId: string, config: ApiConfiguration): str
|
||||
* Resolve the model ID for a given provider and mode from the ApiConfiguration.
|
||||
* Uses mode-specific model ID fields when available, falls back to generic fields.
|
||||
*/
|
||||
export function resolveModelId(providerId: string, mode: Mode, config: ApiConfiguration): string | undefined {
|
||||
function resolveModelId(providerId: string, mode: Mode, config: ApiConfiguration): string | undefined {
|
||||
// VS Code LM has no plain model-id field: the selected model is stored as a
|
||||
// structured LanguageModelChatSelector ({vendor, family, ...}) in
|
||||
// plan/actModeVsCodeLmModelSelector. The SDK ProviderConfig only carries a
|
||||
@@ -385,16 +409,6 @@ export function resolveModelId(providerId: string, mode: Mode, config: ApiConfig
|
||||
return selector ? stringifyVsCodeLmModelSelector(selector) || undefined : undefined
|
||||
}
|
||||
|
||||
if (providerId === "sapaicore") {
|
||||
const genericField = mode === "plan" ? "planModeApiModelId" : "actModeApiModelId"
|
||||
const legacyField = mode === "plan" ? "planModeSapAiCoreModelId" : "actModeSapAiCoreModelId"
|
||||
return (
|
||||
(config[genericField] as string | undefined)?.trim() ||
|
||||
(config[legacyField] as string | undefined)?.trim() ||
|
||||
undefined
|
||||
)
|
||||
}
|
||||
|
||||
// Check provider-specific mode model ID fields.
|
||||
// If the provider has a dedicated field, do not fall back to generic
|
||||
// *ModeApiModelId. Those generic slots may contain a stale model from a
|
||||
@@ -447,28 +461,7 @@ export function normalizeSdkBaseUrl(providerId: string, baseUrl: unknown): strin
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export function resolveVertexProviderConfig(config: ApiConfiguration): Pick<ProviderSettings, "gcp" | "region"> {
|
||||
let providerSettingsProjectId: string | undefined
|
||||
let providerSettingsRegion: string | undefined
|
||||
try {
|
||||
const settings = getProviderSettingsManager().getProviderSettings("vertex")
|
||||
providerSettingsProjectId = settings?.gcp?.projectId?.trim() || undefined
|
||||
providerSettingsRegion = settings?.gcp?.region?.trim() || settings?.region?.trim() || undefined
|
||||
} catch {
|
||||
Logger.warn("[SessionFactory] Failed to read Vertex settings from providers.json")
|
||||
}
|
||||
|
||||
const region = (providerSettingsRegion ?? config.vertexRegion?.trim()) || undefined
|
||||
return {
|
||||
region,
|
||||
gcp: {
|
||||
projectId: (providerSettingsProjectId ?? config.vertexProjectId?.trim()) || undefined,
|
||||
region,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveBaseUrl(providerId: string, config: ApiConfiguration): string | undefined {
|
||||
function resolveBaseUrl(providerId: string, config: ApiConfiguration): string | undefined {
|
||||
const baseUrlMap: Record<string, keyof ApiConfiguration> = {
|
||||
anthropic: "anthropicBaseUrl",
|
||||
openai: "openAiBaseUrl",
|
||||
@@ -490,6 +483,241 @@ export function resolveBaseUrl(providerId: string, config: ApiConfiguration): st
|
||||
return undefined
|
||||
}
|
||||
|
||||
function mergeRuntimeObject<T extends object>(first: T | undefined, second: T | undefined): T | undefined {
|
||||
if (!first) {
|
||||
return second
|
||||
}
|
||||
if (!second) {
|
||||
return first
|
||||
}
|
||||
const merged: Record<string, unknown> = { ...(first as Record<string, unknown>) }
|
||||
for (const [key, value] of Object.entries(second)) {
|
||||
if (value !== undefined) {
|
||||
merged[key] = value
|
||||
}
|
||||
}
|
||||
return merged as T
|
||||
}
|
||||
|
||||
function isSdkAwsAuthentication(value: unknown): value is NonNullable<RuntimeProviderConfig["aws"]>["authentication"] {
|
||||
return value === "iam" || value === "api-key" || value === "apikey" || value === "profile"
|
||||
}
|
||||
|
||||
function normalizeSdkAwsAuthentication(value: unknown): NonNullable<RuntimeProviderConfig["aws"]>["authentication"] | undefined {
|
||||
if (value === "credentials") {
|
||||
return "iam"
|
||||
}
|
||||
return isSdkAwsAuthentication(value) ? value : undefined
|
||||
}
|
||||
|
||||
function toRuntimeAwsConfig(aws: ReturnType<typeof buildEffectiveProviderConfig>["aws"]): RuntimeProviderConfig["aws"] {
|
||||
if (!aws) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
...aws,
|
||||
authentication: normalizeSdkAwsAuthentication(aws.authentication),
|
||||
}
|
||||
}
|
||||
|
||||
function isBedrockApiKeyAuthentication(authentication: unknown): boolean {
|
||||
return authentication === "api-key" || authentication === "apikey"
|
||||
}
|
||||
|
||||
function isSdkSapApi(value: unknown): value is NonNullable<RuntimeProviderConfig["sap"]>["api"] {
|
||||
return value === "orchestration" || value === "foundation-models"
|
||||
}
|
||||
|
||||
function isSdkOcaMode(value: unknown): value is NonNullable<RuntimeProviderConfig["oca"]>["mode"] {
|
||||
return value === "internal" || value === "external"
|
||||
}
|
||||
|
||||
function toRuntimeApiLine(value: unknown): RuntimeProviderConfig["apiLine"] {
|
||||
return value === "china" || value === "international" ? value : undefined
|
||||
}
|
||||
|
||||
function toRuntimeSapConfig(sap: ReturnType<typeof buildEffectiveProviderConfig>["sap"]): RuntimeProviderConfig["sap"] {
|
||||
if (!sap) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
...sap,
|
||||
api: isSdkSapApi(sap.api) ? sap.api : undefined,
|
||||
defaultSettings: sap.defaultSettings ? { ...sap.defaultSettings } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function toRuntimeOcaConfig(oca: ReturnType<typeof buildEffectiveProviderConfig>["oca"]): RuntimeProviderConfig["oca"] {
|
||||
if (!oca) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
...oca,
|
||||
mode: isSdkOcaMode(oca.mode) ? oca.mode : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function readModeSpecificSapDeploymentId(providerId: string, mode: Mode, config: ApiConfiguration): string | undefined {
|
||||
if (providerId !== "sapaicore") {
|
||||
return undefined
|
||||
}
|
||||
const value = mode === "plan" ? config.planModeSapAiCoreDeploymentId : config.actModeSapAiCoreDeploymentId
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined
|
||||
}
|
||||
|
||||
function readModeSpecificBedrockCustomModelBaseId(providerId: string, mode: Mode, config: ApiConfiguration): string | undefined {
|
||||
if (providerId !== "bedrock") {
|
||||
return undefined
|
||||
}
|
||||
const value = mode === "plan" ? config.planModeAwsBedrockCustomModelBaseId : config.actModeAwsBedrockCustomModelBaseId
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined
|
||||
}
|
||||
|
||||
function readRemoteBedrockCustomModelBaseId(modelId: string | undefined): string | undefined {
|
||||
if (!modelId) {
|
||||
return undefined
|
||||
}
|
||||
return readRemoteProviderModelSettings("bedrock")?.bedrockCustomModels?.find((model) => model.name === modelId)?.baseModelId
|
||||
}
|
||||
|
||||
function readRemoteProviderModelSettings(providerId: string): RemoteProviderModelSettings[string] | undefined {
|
||||
try {
|
||||
const manager = StateManager.get() as { getRemoteConfigSettings?: () => unknown }
|
||||
const settings = manager.getRemoteConfigSettings?.()
|
||||
if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
|
||||
return undefined
|
||||
}
|
||||
const remoteProviderModelSettings = (settings as { remoteProviderModelSettings?: RemoteProviderModelSettings })
|
||||
.remoteProviderModelSettings
|
||||
const sdkProviderId = toSdkProviderId(providerId)
|
||||
return remoteProviderModelSettings?.[sdkProviderId] ?? remoteProviderModelSettings?.[providerId]
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRemoteAllowedModelId(providerId: string, modelId: string | undefined): string | undefined {
|
||||
const remoteModelSettings = readRemoteProviderModelSettings(providerId)
|
||||
if (!remoteModelSettings?.models?.length && !remoteModelSettings?.bedrockCustomModels?.length) {
|
||||
return modelId
|
||||
}
|
||||
|
||||
const allowedModelIds = [
|
||||
...(remoteModelSettings.models ?? []).map((model) => model.id),
|
||||
...(remoteModelSettings.bedrockCustomModels ?? []).map((model) => model.name),
|
||||
].filter((value) => value.trim().length > 0)
|
||||
if (allowedModelIds.length === 0) {
|
||||
return modelId
|
||||
}
|
||||
return modelId && allowedModelIds.includes(modelId) ? modelId : allowedModelIds[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime provider config is built from the same effective SDK/legacy boundary
|
||||
* as the catalog. A partial providers.json entry is not authoritative; missing
|
||||
* fields are filled from legacy StateManager overlays so old installs keep
|
||||
* working while the SDK-owned provider shape becomes the runtime contract.
|
||||
*/
|
||||
export function buildRuntimeProviderConfig(providerId: string, mode: Mode, apiConfig: ApiConfiguration): RuntimeProviderConfig {
|
||||
const sharedApiConfig = mirrorPlanActApiConfiguration(apiConfig)
|
||||
const runtimeProviderId = toVscodeSupportedProvider(providerId)
|
||||
const persistedProviderConfig = resolvePersistedProviderConfig(runtimeProviderId)
|
||||
const persistedProviderSettings = resolvePersistedProviderSettings(runtimeProviderId)
|
||||
const parsedProviderId = parseProviderId(runtimeProviderId)
|
||||
const effectiveConfig = buildEffectiveProviderConfig(parsedProviderId)
|
||||
const remoteConfig = buildRemoteProviderConfig(parsedProviderId)
|
||||
const rawModelId =
|
||||
persistedProviderConfig?.modelId ??
|
||||
resolveModelId(runtimeProviderId, mode, sharedApiConfig) ??
|
||||
getDefaultModelIdForProvider(runtimeProviderId)
|
||||
const modelId = resolveRemoteAllowedModelId(runtimeProviderId, rawModelId)
|
||||
const apiKey =
|
||||
remoteConfig.apiKey ??
|
||||
persistedProviderConfig?.apiKey ??
|
||||
effectiveConfig.apiKey ??
|
||||
resolveApiKey(runtimeProviderId, apiConfig)
|
||||
const persistedExplicitBaseUrl =
|
||||
typeof persistedProviderSettings?.baseUrl === "string" && persistedProviderSettings.baseUrl.trim().length > 0
|
||||
? persistedProviderConfig?.baseUrl
|
||||
: undefined
|
||||
const baseUrl =
|
||||
remoteConfig.baseUrl ??
|
||||
(parsedProviderId === "oca"
|
||||
? (effectiveConfig.baseUrl ??
|
||||
resolveBaseUrl(runtimeProviderId, sharedApiConfig) ??
|
||||
persistedExplicitBaseUrl ??
|
||||
persistedProviderConfig?.baseUrl)
|
||||
: (persistedExplicitBaseUrl ??
|
||||
effectiveConfig.baseUrl ??
|
||||
resolveBaseUrl(runtimeProviderId, sharedApiConfig) ??
|
||||
persistedProviderConfig?.baseUrl))
|
||||
const bedrockCustomModelBaseId =
|
||||
readRemoteBedrockCustomModelBaseId(modelId) ??
|
||||
readModeSpecificBedrockCustomModelBaseId(runtimeProviderId, mode, sharedApiConfig)
|
||||
const mergedAwsBase = mergeRuntimeObject(
|
||||
mergeRuntimeObject(effectiveConfig.aws, persistedProviderConfig?.aws),
|
||||
remoteConfig.aws,
|
||||
)
|
||||
const mergedAws = bedrockCustomModelBaseId
|
||||
? { ...(mergedAwsBase ?? {}), customModelBaseId: bedrockCustomModelBaseId }
|
||||
: mergedAwsBase
|
||||
const aws = toRuntimeAwsConfig(mergedAws)
|
||||
const gcp = mergeRuntimeObject(mergeRuntimeObject(effectiveConfig.gcp, persistedProviderConfig?.gcp), remoteConfig.gcp)
|
||||
const azure = mergeRuntimeObject(
|
||||
mergeRuntimeObject(effectiveConfig.azure, persistedProviderConfig?.azure),
|
||||
remoteConfig.azure,
|
||||
)
|
||||
const apiLine = toRuntimeApiLine(remoteConfig.apiLine ?? persistedProviderConfig?.apiLine ?? effectiveConfig.apiLine)
|
||||
const modeSpecificSapDeploymentId = readModeSpecificSapDeploymentId(runtimeProviderId, mode, sharedApiConfig)
|
||||
const mergedSapBase = mergeRuntimeObject(
|
||||
mergeRuntimeObject(effectiveConfig.sap, persistedProviderConfig?.sap),
|
||||
remoteConfig.sap,
|
||||
)
|
||||
const mergedSap = modeSpecificSapDeploymentId
|
||||
? { ...(mergedSapBase ?? {}), deploymentId: modeSpecificSapDeploymentId }
|
||||
: mergedSapBase
|
||||
const sap = toRuntimeSapConfig(mergedSap)
|
||||
const oca = toRuntimeOcaConfig(
|
||||
mergeRuntimeObject(mergeRuntimeObject(persistedProviderSettings?.oca, effectiveConfig.oca), remoteConfig.oca),
|
||||
)
|
||||
const region =
|
||||
gcp?.region ?? mergedAws?.region ?? remoteConfig.region ?? effectiveConfig.region ?? persistedProviderConfig?.region
|
||||
const runtimeApiKey =
|
||||
runtimeProviderId === "bedrock" && !isBedrockApiKeyAuthentication(aws?.authentication) ? undefined : apiKey
|
||||
const {
|
||||
apiKey: _persistedApiKey,
|
||||
thinking: _persistedThinking,
|
||||
reasoningEffort: _persistedReasoningEffort,
|
||||
thinkingBudgetTokens: _persistedThinkingBudgetTokens,
|
||||
...persistedProviderConfigWithoutRuntimeOnlyFields
|
||||
} = persistedProviderConfig ?? {}
|
||||
|
||||
return {
|
||||
...persistedProviderConfigWithoutRuntimeOnlyFields,
|
||||
providerId: toSdkProviderId(runtimeProviderId),
|
||||
modelId: modelId || getDefaultModelIdForProvider(runtimeProviderId) || "",
|
||||
apiLine,
|
||||
...(runtimeApiKey ? { apiKey: runtimeApiKey } : {}),
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
...((remoteConfig.headers ?? effectiveConfig.headers)
|
||||
? { headers: { ...(remoteConfig.headers ?? effectiveConfig.headers) } }
|
||||
: {}),
|
||||
...(effectiveConfig.auth?.accessToken ? { accessToken: effectiveConfig.auth.accessToken } : {}),
|
||||
...(effectiveConfig.auth?.refreshToken ? { refreshToken: effectiveConfig.auth.refreshToken } : {}),
|
||||
...(effectiveConfig.auth?.accountId ? { accountId: effectiveConfig.auth.accountId } : {}),
|
||||
...(region ? { region } : {}),
|
||||
...(aws ? { aws } : {}),
|
||||
...(gcp ? { gcp } : {}),
|
||||
...(azure ? { azure } : {}),
|
||||
...(sap ? { sap } : {}),
|
||||
...(oca ? { oca } : {}),
|
||||
...(mergedAws?.useCrossRegionInference !== undefined
|
||||
? { useCrossRegionInference: mergedAws.useCrossRegionInference }
|
||||
: {}),
|
||||
...(mergedAws?.useGlobalInference !== undefined ? { useGlobalInference: mergedAws.useGlobalInference } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session config builder
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -498,8 +726,9 @@ export function resolveBaseUrl(providerId: string, config: ApiConfiguration): st
|
||||
* Build a CoreSessionConfig from the current state.
|
||||
*
|
||||
* Reads provider settings from the classic StateManager's ApiConfiguration
|
||||
* (which correctly reads from globalState.json + secrets.json), then resolves
|
||||
* the provider, model, and API key for the current mode (plan/act).
|
||||
* (which correctly reads from globalState.json + secrets.json), mirrors legacy
|
||||
* plan/act slots to one shared selection, then resolves the provider, model,
|
||||
* and API key for the current mode's runtime behavior.
|
||||
*
|
||||
* This replaces the previous two-path approach (SDK ProviderSettingsManager +
|
||||
* StateManager.buildApiHandlerSettings) which both failed silently.
|
||||
@@ -519,53 +748,33 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
let apiKey: string | undefined
|
||||
let baseUrl: string | undefined
|
||||
let apiConfig: ApiConfiguration | undefined
|
||||
// Cloud-provider structured options. The core runtime reads these from
|
||||
// CoreSessionConfig.providerConfig; without them the SDK gateway never receives
|
||||
// region/project/auth fields for inference calls.
|
||||
let bedrockProviderConfig: BedrockProviderConfig | undefined
|
||||
let vertexProviderConfig: Pick<ProviderSettings, "gcp" | "region"> | undefined
|
||||
let sapProviderConfig: SapProviderConfig | undefined
|
||||
let sdkProviderConfig: CoreSessionConfig["providerConfig"] | undefined
|
||||
|
||||
try {
|
||||
const stateManager = StateManager.get()
|
||||
apiConfig = stateManager.getApiConfiguration()
|
||||
apiConfig = mirrorPlanActApiConfiguration(stateManager.getApiConfiguration())
|
||||
|
||||
// Resolve the provider for the current mode
|
||||
// Resolve the shared provider selection. The mode still controls tool
|
||||
// access and prompting, but not provider/model choice.
|
||||
const modeProvider = mode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider
|
||||
providerId = modeProvider
|
||||
providerId = modeProvider ? toVscodeSupportedProvider(modeProvider, DEFAULT_PROVIDER_ID) : undefined
|
||||
if (modeProvider && providerId !== modeProvider) {
|
||||
Logger.warn(`[SessionFactory] Provider ${modeProvider} is unsupported in VS Code; using ${providerId} for runtime`)
|
||||
}
|
||||
|
||||
if (providerId) {
|
||||
// Resolve API key
|
||||
apiKey = resolveApiKey(providerId, apiConfig)
|
||||
|
||||
// Resolve model ID
|
||||
modelId = resolveModelId(providerId, mode, apiConfig)
|
||||
|
||||
// Resolve base URL
|
||||
baseUrl = resolveBaseUrl(providerId, apiConfig)
|
||||
|
||||
// Resolve Bedrock region + AWS authentication options from the legacy
|
||||
// ApiConfiguration (StateManager is the VSCode source of truth, not
|
||||
// providers.json).
|
||||
if (providerId === "bedrock") {
|
||||
bedrockProviderConfig = buildBedrockProviderConfig(apiConfig, mode)
|
||||
}
|
||||
|
||||
if (providerId === "vertex") {
|
||||
vertexProviderConfig = resolveVertexProviderConfig(apiConfig)
|
||||
}
|
||||
|
||||
if (providerId === "sapaicore") {
|
||||
sapProviderConfig = buildSapProviderConfig(apiConfig, mode)
|
||||
baseUrl = sapProviderConfig.baseUrl
|
||||
}
|
||||
const sdkProviderId = toSdkProviderId(providerId)
|
||||
sdkProviderConfig = buildRuntimeProviderConfig(providerId, mode, apiConfig)
|
||||
modelId = sdkProviderConfig.modelId
|
||||
apiKey = sdkProviderConfig.apiKey
|
||||
baseUrl = sdkProviderConfig.baseUrl
|
||||
|
||||
Logger.log(
|
||||
`[SessionFactory] Resolved from StateManager: provider=${providerId}, model=${modelId}, hasApiKey=${!!apiKey}`,
|
||||
`[SessionFactory] Resolved provider config: provider=${providerId}, sdkProvider=${sdkProviderId}, model=${modelId}, source=effective, hasApiKey=${!!apiKey}`,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.warn("[SessionFactory] StateManager credential resolution failed:", error)
|
||||
Logger.warn("[SessionFactory] Provider config resolution failed:", error)
|
||||
}
|
||||
|
||||
// Fallback: try SDK's ProviderSettingsManager only when StateManager did not
|
||||
@@ -576,16 +785,20 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
try {
|
||||
const dataDir = resolveDataDir()
|
||||
const manager = getProviderSettingsManager(dataDir)
|
||||
const lastUsed = manager.getLastUsedProviderSettings({
|
||||
isClinePassEnabled: getFeatureFlagsService().getBooleanFlagEnabled(FeatureFlag.CLINE_PASS),
|
||||
})
|
||||
const lastUsed = manager.getLastUsedProviderSettings()
|
||||
|
||||
if (lastUsed?.provider && lastUsed?.apiKey) {
|
||||
if (lastUsed?.provider && !isVscodeUnsupportedProvider(lastUsed.provider)) {
|
||||
const lastUsedConfig = manager.getProviderConfig(lastUsed.provider, { includeKnownModels: false })
|
||||
providerId = lastUsed.provider
|
||||
modelId = lastUsed.model
|
||||
apiKey = lastUsed.apiKey
|
||||
baseUrl = lastUsed.baseUrl
|
||||
sdkProviderConfig = lastUsedConfig
|
||||
modelId = lastUsedConfig?.modelId ?? lastUsed.model
|
||||
apiKey = lastUsedConfig?.apiKey ?? lastUsed.apiKey
|
||||
baseUrl = lastUsedConfig?.baseUrl ?? lastUsed.baseUrl
|
||||
Logger.log(`[SessionFactory] Using SDK provider fallback: ${providerId}/${modelId}`)
|
||||
} else if (lastUsed?.provider) {
|
||||
Logger.warn(
|
||||
`[SessionFactory] Ignoring unsupported SDK provider fallback ${lastUsed.provider}; using ${DEFAULT_PROVIDER_ID}`,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.warn("[SessionFactory] SDK ProviderSettingsManager fallback failed:", error)
|
||||
@@ -596,15 +809,13 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
// session factory share one source of truth for default models.
|
||||
providerId = providerId ?? DEFAULT_PROVIDER_ID
|
||||
modelId = modelId ?? getDefaultModelIdForProvider(providerId) ?? getDefaultModelIdForProvider(DEFAULT_PROVIDER_ID) ?? ""
|
||||
if (!apiKey && apiConfig) {
|
||||
const shouldResolveLegacyApiKey =
|
||||
providerId !== "bedrock" || isBedrockApiKeyAuthentication(sdkProviderConfig?.aws?.authentication)
|
||||
if (!apiKey && apiConfig && shouldResolveLegacyApiKey) {
|
||||
apiKey = resolveApiKey(providerId, apiConfig)
|
||||
}
|
||||
apiKey = apiKey ?? ""
|
||||
const maxTokensPerTurn = providerId === "openai" ? resolveOpenAiCompatibleMaxTokens(apiConfig, mode) : undefined
|
||||
const reasoningConfig =
|
||||
providerId === "oca"
|
||||
? (resolveOcaReasoningConfig(mode, apiConfig) ?? resolveProviderReasoningConfig(providerId))
|
||||
: resolveProviderReasoningConfig(providerId)
|
||||
const reasoningConfig = resolveProviderReasoningConfig(providerId, mode, apiConfig ?? {})
|
||||
|
||||
// Build the system prompt using the shared prompt builder. Core still
|
||||
// expects callers to provide a concrete systemPrompt, but the prompt builder
|
||||
@@ -657,20 +868,12 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
// extension's "openai"). Convert before handing the id to core.
|
||||
const sdkProviderId = toSdkProviderId(providerId)
|
||||
|
||||
// Always pass a providerConfig so the proxy/CA-aware fetch reaches the SDK
|
||||
// gateway; without it the agent loop uses bare global fetch and corporate
|
||||
// proxy/self-signed CA setups fail on JetBrains and CLI. Cloud providers
|
||||
// additionally need structured options (region/project/auth/SAP OAuth), which core
|
||||
// reads from providerConfig in createAgentModelFromConfig.
|
||||
const cloudProviderConfig = bedrockProviderConfig ?? vertexProviderConfig ?? sapProviderConfig
|
||||
// Spread the cloud config first so the explicit fields below — notably the
|
||||
// proxy/CA-aware fetch — can never be clobbered if those types gain matching keys.
|
||||
const providerConfig = {
|
||||
...(cloudProviderConfig ?? {}),
|
||||
...(sdkProviderConfig ?? {}),
|
||||
providerId: sdkProviderId,
|
||||
modelId,
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
...(baseUrl !== undefined ? { baseUrl } : {}),
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
fetch,
|
||||
}
|
||||
|
||||
@@ -697,7 +900,6 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
disableMcpSettingsTools: true,
|
||||
mode: mode === "plan" ? "plan" : "act",
|
||||
...reasoningConfig,
|
||||
...(maxTokensPerTurn !== undefined ? { maxTokensPerTurn } : {}),
|
||||
maxIterations: undefined,
|
||||
logger: sdkLogger,
|
||||
extensionContext: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ModelInfo } from "@shared/api"
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { _testing, createProviderCatalog } from "./catalog"
|
||||
import type {
|
||||
EffectiveProviderConfig,
|
||||
Fingerprint,
|
||||
@@ -11,13 +12,33 @@ import type {
|
||||
import { computeConfigFingerprint } from "./fingerprint"
|
||||
import { parseProviderId } from "./provider-id"
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolveProviderConfig: vi.fn(),
|
||||
listLocalProviders: vi.fn(),
|
||||
getBooleanFlagEnabled: vi.fn(() => false),
|
||||
pollFeatureFlags: vi.fn(async (): Promise<void> => undefined),
|
||||
getProviderSettings: vi.fn((): any => undefined),
|
||||
}))
|
||||
const mocks = vi.hoisted(() => {
|
||||
let apiConfiguration: Record<string, unknown> = {}
|
||||
let remoteConfigSettings: Record<string, unknown> = {}
|
||||
let providerSettingsById: Record<string, unknown> = {}
|
||||
return {
|
||||
resolveProviderConfig: vi.fn(),
|
||||
listLocalProviders: vi.fn(),
|
||||
setApiConfiguration(value: Record<string, unknown>): void {
|
||||
apiConfiguration = value
|
||||
},
|
||||
setProviderSettings(value: Record<string, unknown>): void {
|
||||
providerSettingsById = value
|
||||
},
|
||||
getApiConfiguration(): Record<string, unknown> {
|
||||
return apiConfiguration
|
||||
},
|
||||
setRemoteConfigSettings(value: Record<string, unknown>): void {
|
||||
remoteConfigSettings = value
|
||||
},
|
||||
getRemoteConfigSettings(): Record<string, unknown> {
|
||||
return remoteConfigSettings
|
||||
},
|
||||
getProviderSettings(providerId: string): unknown {
|
||||
return providerSettingsById[providerId]
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@cline/core", async (importOriginal: any) => {
|
||||
const actual = await importOriginal()
|
||||
@@ -28,15 +49,13 @@ vi.mock("@cline/core", async (importOriginal: any) => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@/services/feature-flags", () => ({
|
||||
getFeatureFlagsService: () => ({
|
||||
getBooleanFlagEnabled: mocks.getBooleanFlagEnabled,
|
||||
poll: mocks.pollFeatureFlags,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("@/services/logging/distinctId", () => ({
|
||||
setDistinctId: vi.fn(),
|
||||
vi.mock("@/core/storage/StateManager", () => ({
|
||||
StateManager: {
|
||||
get: () => ({
|
||||
getApiConfiguration: mocks.getApiConfiguration,
|
||||
getRemoteConfigSettings: mocks.getRemoteConfigSettings,
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("../provider-migration", () => ({
|
||||
@@ -54,24 +73,12 @@ type TestReader = ProviderConfigReader & {
|
||||
emit(event: ProviderConfigChange): void
|
||||
}
|
||||
|
||||
// Warm the catalog module graph once before any test runs. catalog.ts statically
|
||||
// pulls in @cline/core, @cline/llms and @cline/shared, so the first test to call
|
||||
// `await import("./catalog")` otherwise pays the entire (>5s on CI) import cost
|
||||
// inside its own 5s test timeout and flakily fails. Importing here moves that cost
|
||||
// outside any per-test clock (hooks get a generous timeout of their own).
|
||||
beforeAll(async () => {
|
||||
await import("./catalog")
|
||||
}, 60_000)
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.resolveProviderConfig.mockReset()
|
||||
mocks.listLocalProviders.mockReset()
|
||||
mocks.getBooleanFlagEnabled.mockReset()
|
||||
mocks.getBooleanFlagEnabled.mockReturnValue(false)
|
||||
mocks.pollFeatureFlags.mockReset()
|
||||
mocks.pollFeatureFlags.mockResolvedValue(undefined)
|
||||
mocks.getProviderSettings.mockReset()
|
||||
mocks.getProviderSettings.mockReturnValue(undefined)
|
||||
mocks.setApiConfiguration({})
|
||||
mocks.setRemoteConfigSettings({})
|
||||
mocks.setProviderSettings({})
|
||||
})
|
||||
|
||||
function fingerprint(value: string): Fingerprint {
|
||||
@@ -128,7 +135,6 @@ function deferred<T>() {
|
||||
|
||||
describe("ProviderCatalog Phase 3.1 cache", () => {
|
||||
it("returns cache hit only when provider and fingerprint both match", async () => {
|
||||
const { _testing } = await import("./catalog")
|
||||
let now = 100
|
||||
const cache = _testing.createProviderModelsCache({ ttlMs: 50, now: () => now })
|
||||
const ollama = parseProviderId("ollama")
|
||||
@@ -147,7 +153,6 @@ describe("ProviderCatalog Phase 3.1 cache", () => {
|
||||
})
|
||||
|
||||
it("does not collide for different fingerprints", async () => {
|
||||
const { _testing } = await import("./catalog")
|
||||
const cache = _testing.createProviderModelsCache({ ttlMs: 50, now: () => 100 })
|
||||
const providerId = parseProviderId("ollama")
|
||||
const fpA = fingerprint("a")
|
||||
@@ -164,7 +169,6 @@ describe("ProviderCatalog Phase 3.1 cache", () => {
|
||||
})
|
||||
|
||||
it("reuses in-flight promise only when provider and fingerprint both match", async () => {
|
||||
const { _testing } = await import("./catalog")
|
||||
const cache = _testing.createProviderModelsCache({ ttlMs: 50, now: () => 100 })
|
||||
const ollama = parseProviderId("ollama")
|
||||
const lmstudio = parseProviderId("lmstudio")
|
||||
@@ -190,7 +194,6 @@ describe("ProviderCatalog Phase 3.1 cache", () => {
|
||||
})
|
||||
|
||||
it("returns undefined and removes expired records", async () => {
|
||||
const { _testing } = await import("./catalog")
|
||||
let now = 100
|
||||
const cache = _testing.createProviderModelsCache({ ttlMs: 10, now: () => now })
|
||||
const providerId = parseProviderId("ollama")
|
||||
@@ -207,7 +210,6 @@ describe("ProviderCatalog Phase 3.1 cache", () => {
|
||||
|
||||
describe("ProviderCatalog Phase 3.2 resolveModels happy path", () => {
|
||||
it("keeps lowercase nousresearch in extension results while using SDK casing at the SDK boundary", async () => {
|
||||
const { createProviderCatalog } = await import("./catalog")
|
||||
mocks.resolveProviderConfig.mockResolvedValue({
|
||||
modelId: "DeepHermes-3-Llama-3-3-70B-Preview",
|
||||
knownModels: {
|
||||
@@ -230,7 +232,6 @@ describe("ProviderCatalog Phase 3.2 resolveModels happy path", () => {
|
||||
})
|
||||
|
||||
it("resolves SDK knownModels, adapts model info, and uses SDK default when present", async () => {
|
||||
const { createProviderCatalog } = await import("./catalog")
|
||||
mocks.resolveProviderConfig.mockResolvedValue({
|
||||
modelId: "sdk-default",
|
||||
baseUrl: "https://provider.example.com",
|
||||
@@ -271,8 +272,143 @@ describe("ProviderCatalog Phase 3.2 resolveModels happy path", () => {
|
||||
)
|
||||
})
|
||||
|
||||
it("applies remote model allowlists after SDK model resolution", async () => {
|
||||
mocks.resolveProviderConfig.mockResolvedValue({
|
||||
modelId: "blocked-model",
|
||||
knownModels: {
|
||||
"allowed-model": { id: "allowed-model", contextWindow: 128_000 },
|
||||
"blocked-model": { id: "blocked-model", contextWindow: 64_000 },
|
||||
},
|
||||
})
|
||||
mocks.setRemoteConfigSettings({
|
||||
remoteProviderModelSettings: {
|
||||
anthropic: {
|
||||
models: [
|
||||
{ id: "allowed-model", thinkingBudgetTokens: 4096 },
|
||||
{ id: "remote-only-model", contextWindow: 32_000, maxTokens: 4_096 },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
const providerId = parseProviderId("anthropic")
|
||||
|
||||
const catalog = createProviderCatalog(makeReader({ providerId }))
|
||||
const result = await catalog.resolveModels(providerId)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) throw new Error("expected success")
|
||||
expect([...result.models.keys()]).toEqual(["allowed-model", "remote-only-model"])
|
||||
expect(result.defaultModelId).toBe("allowed-model")
|
||||
expect(result.source).toBe("host-adapter")
|
||||
expect(result.models.get("allowed-model")?.thinkingConfig?.maxBudget).toBe(4096)
|
||||
expect(result.models.get("remote-only-model")).toMatchObject({ contextWindow: 32_000, maxTokens: 4_096 })
|
||||
const cached = catalog.peekModels(providerId)
|
||||
expect(cached?.ok).toBe(true)
|
||||
if (!cached?.ok) throw new Error("expected cached success")
|
||||
expect([...cached.models.keys()]).toEqual(["allowed-model", "remote-only-model"])
|
||||
})
|
||||
|
||||
it("adds remote Bedrock custom models using base model metadata", async () => {
|
||||
mocks.resolveProviderConfig.mockResolvedValue({
|
||||
modelId: "anthropic.claude-sonnet-4-6",
|
||||
knownModels: {
|
||||
"anthropic.claude-sonnet-4-6": {
|
||||
id: "anthropic.claude-sonnet-4-6",
|
||||
name: "Claude Sonnet",
|
||||
contextWindow: 200_000,
|
||||
},
|
||||
"blocked-model": { id: "blocked-model" },
|
||||
},
|
||||
})
|
||||
mocks.setRemoteConfigSettings({
|
||||
remoteProviderModelSettings: {
|
||||
bedrock: {
|
||||
models: [{ id: "anthropic.claude-sonnet-4-6" }],
|
||||
bedrockCustomModels: [
|
||||
{
|
||||
name: "application-inference-profile",
|
||||
baseModelId: "anthropic.claude-sonnet-4-6",
|
||||
thinkingBudgetTokens: 2048,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
const providerId = parseProviderId("bedrock")
|
||||
|
||||
const result = await createProviderCatalog(makeReader({ providerId })).resolveModels(providerId)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) throw new Error("expected success")
|
||||
expect([...result.models.keys()]).toEqual(["anthropic.claude-sonnet-4-6", "application-inference-profile"])
|
||||
expect(result.models.get("application-inference-profile")).toMatchObject({
|
||||
name: "application-inference-profile",
|
||||
contextWindow: 200_000,
|
||||
thinkingConfig: { maxBudget: 2048 },
|
||||
})
|
||||
})
|
||||
|
||||
it("passes legacy OpenAI-compatible config to SDK model resolution through the SDK provider id", async () => {
|
||||
mocks.setApiConfiguration({
|
||||
openAiApiKey: "legacy-openai-key",
|
||||
openAiBaseUrl: "https://legacy-openai.example/v1",
|
||||
openAiHeaders: { "x-provider": "legacy" },
|
||||
azureApiVersion: "2025-01-01-preview",
|
||||
})
|
||||
mocks.resolveProviderConfig.mockResolvedValue({
|
||||
modelId: "custom-model",
|
||||
knownModels: { "custom-model": { id: "custom-model" } },
|
||||
})
|
||||
const providerId = parseProviderId("openai-compatible")
|
||||
const { buildEffectiveProviderConfig } = await import("./effective-config")
|
||||
|
||||
const result = await createProviderCatalog(makeReader(buildEffectiveProviderConfig(providerId))).resolveModels(providerId)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
const [, , sdkConfig] = mocks.resolveProviderConfig.mock.calls[0]
|
||||
expect(sdkConfig).toMatchObject({
|
||||
providerId: "openai-compatible",
|
||||
apiKey: "legacy-openai-key",
|
||||
baseUrl: "https://legacy-openai.example/v1",
|
||||
headers: { "x-provider": "legacy" },
|
||||
azure: { apiVersion: "2025-01-01-preview" },
|
||||
})
|
||||
})
|
||||
|
||||
it("omits stale Bedrock API keys from SDK model resolution when auth is IAM", async () => {
|
||||
mocks.resolveProviderConfig.mockResolvedValue({
|
||||
modelId: "anthropic.claude-3-7-sonnet",
|
||||
knownModels: {
|
||||
"anthropic.claude-3-7-sonnet": { id: "anthropic.claude-3-7-sonnet" },
|
||||
},
|
||||
})
|
||||
const providerId = parseProviderId("bedrock")
|
||||
const config: EffectiveProviderConfig = {
|
||||
providerId,
|
||||
apiKey: "stale-bedrock-api-key",
|
||||
aws: {
|
||||
authentication: "credentials",
|
||||
region: "us-east-1",
|
||||
},
|
||||
}
|
||||
|
||||
const result = await createProviderCatalog(makeReader(config)).resolveModels(providerId)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
const [, , sdkConfig] = mocks.resolveProviderConfig.mock.calls[0]
|
||||
expect(sdkConfig).toMatchObject({
|
||||
providerId: "bedrock",
|
||||
modelId: "",
|
||||
region: "us-east-1",
|
||||
aws: {
|
||||
authentication: "iam",
|
||||
region: "us-east-1",
|
||||
},
|
||||
})
|
||||
expect(sdkConfig).not.toHaveProperty("apiKey")
|
||||
})
|
||||
|
||||
it("falls back to first model when SDK default is absent", async () => {
|
||||
const { createProviderCatalog } = await import("./catalog")
|
||||
mocks.resolveProviderConfig.mockResolvedValue({
|
||||
modelId: "missing-default",
|
||||
baseUrl: "https://provider.example.com",
|
||||
@@ -291,7 +427,6 @@ describe("ProviderCatalog Phase 3.2 resolveModels happy path", () => {
|
||||
})
|
||||
|
||||
it("returns cached result without calling SDK again for the same fingerprint", async () => {
|
||||
const { createProviderCatalog } = await import("./catalog")
|
||||
mocks.resolveProviderConfig.mockResolvedValue({
|
||||
modelId: "m",
|
||||
baseUrl: "https://provider.example.com",
|
||||
@@ -308,7 +443,6 @@ describe("ProviderCatalog Phase 3.2 resolveModels happy path", () => {
|
||||
})
|
||||
|
||||
it("forceRefresh bypasses cache but still uses current fingerprint", async () => {
|
||||
const { createProviderCatalog } = await import("./catalog")
|
||||
mocks.resolveProviderConfig
|
||||
.mockResolvedValueOnce({ modelId: "m1", knownModels: { m1: { id: "m1" } } })
|
||||
.mockResolvedValueOnce({ modelId: "m2", knownModels: { m2: { id: "m2" } } })
|
||||
@@ -325,7 +459,6 @@ describe("ProviderCatalog Phase 3.2 resolveModels happy path", () => {
|
||||
})
|
||||
|
||||
it("concurrent calls with the same provider and fingerprint share one SDK call", async () => {
|
||||
const { createProviderCatalog } = await import("./catalog")
|
||||
const pending = deferred<{ modelId: string; knownModels: Record<string, unknown> }>()
|
||||
mocks.resolveProviderConfig.mockReturnValue(pending.promise)
|
||||
const providerId = parseProviderId("openrouter")
|
||||
@@ -340,7 +473,6 @@ describe("ProviderCatalog Phase 3.2 resolveModels happy path", () => {
|
||||
})
|
||||
|
||||
it("concurrent calls with different fingerprints make separate SDK calls", async () => {
|
||||
const { createProviderCatalog } = await import("./catalog")
|
||||
const firstPending = deferred<{ modelId: string; knownModels: Record<string, unknown> }>()
|
||||
const secondPending = deferred<{ modelId: string; knownModels: Record<string, unknown> }>()
|
||||
mocks.resolveProviderConfig.mockReturnValueOnce(firstPending.promise).mockReturnValueOnce(secondPending.promise)
|
||||
@@ -367,7 +499,6 @@ describe("ProviderCatalog Phase 3.2 resolveModels happy path", () => {
|
||||
})
|
||||
|
||||
it("throws before caching if loaded record does not match requested key", async () => {
|
||||
const { _testing } = await import("./catalog")
|
||||
const providerId = parseProviderId("openrouter")
|
||||
const otherProviderId = parseProviderId("deepseek")
|
||||
const fp = fingerprint("a")
|
||||
@@ -379,7 +510,6 @@ describe("ProviderCatalog Phase 3.2 resolveModels happy path", () => {
|
||||
|
||||
describe("ProviderCatalog Phase 3.3 error path", () => {
|
||||
it("SDK rejection produces an error arm", async () => {
|
||||
const { createProviderCatalog } = await import("./catalog")
|
||||
mocks.resolveProviderConfig.mockRejectedValue(new Error("sdk unavailable"))
|
||||
const providerId = parseProviderId("openrouter")
|
||||
const config: EffectiveProviderConfig = { providerId, apiKey: "same" }
|
||||
@@ -394,7 +524,6 @@ describe("ProviderCatalog Phase 3.3 error path", () => {
|
||||
})
|
||||
|
||||
it("shape validation failure produces a shape error arm", async () => {
|
||||
const { createProviderCatalog } = await import("./catalog")
|
||||
mocks.resolveProviderConfig.mockResolvedValue({
|
||||
modelId: "bad",
|
||||
knownModels: { bad: { name: "missing id" } },
|
||||
@@ -409,7 +538,6 @@ describe("ProviderCatalog Phase 3.3 error path", () => {
|
||||
})
|
||||
|
||||
it("does not cache errors; same fingerprint retries after failure", async () => {
|
||||
const { createProviderCatalog } = await import("./catalog")
|
||||
mocks.resolveProviderConfig
|
||||
.mockRejectedValueOnce(new Error("transient"))
|
||||
.mockResolvedValueOnce({ modelId: "m", knownModels: { m: { id: "m", name: "M" } } })
|
||||
@@ -427,7 +555,6 @@ describe("ProviderCatalog Phase 3.3 error path", () => {
|
||||
})
|
||||
|
||||
it("does not cache shape errors; same fingerprint retries after malformed response", async () => {
|
||||
const { createProviderCatalog } = await import("./catalog")
|
||||
mocks.resolveProviderConfig
|
||||
.mockResolvedValueOnce({ modelId: "bad", knownModels: { bad: { name: "missing id" } } })
|
||||
.mockResolvedValueOnce({ modelId: "good", knownModels: { good: { id: "good", name: "Good" } } })
|
||||
@@ -447,7 +574,6 @@ describe("ProviderCatalog Phase 3.3 error path", () => {
|
||||
|
||||
describe("ProviderCatalog Phase 3.4 store-driven invalidation", () => {
|
||||
it("fields change invalidates old-fingerprint cache and leaves the new fingerprint empty", async () => {
|
||||
const { createProviderCatalog } = await import("./catalog")
|
||||
mocks.resolveProviderConfig
|
||||
.mockResolvedValueOnce({ modelId: "old", knownModels: { old: { id: "old", name: "Old" } } })
|
||||
.mockResolvedValueOnce({ modelId: "new", knownModels: { new: { id: "new", name: "New" } } })
|
||||
@@ -466,7 +592,6 @@ describe("ProviderCatalog Phase 3.4 store-driven invalidation", () => {
|
||||
})
|
||||
|
||||
it("fields change preserves cache record for the latest fingerprint", async () => {
|
||||
const { createProviderCatalog } = await import("./catalog")
|
||||
mocks.resolveProviderConfig.mockResolvedValue({ modelId: "current", knownModels: { current: { id: "current" } } })
|
||||
const providerId = parseProviderId("ollama")
|
||||
const config: EffectiveProviderConfig = { providerId, baseUrl: "http://current.example/v1" }
|
||||
@@ -482,7 +607,6 @@ describe("ProviderCatalog Phase 3.4 store-driven invalidation", () => {
|
||||
})
|
||||
|
||||
it("fields change for one provider does not invalidate another provider", async () => {
|
||||
const { createProviderCatalog } = await import("./catalog")
|
||||
mocks.resolveProviderConfig.mockResolvedValue({
|
||||
modelId: "openrouter-model",
|
||||
knownModels: { "openrouter-model": { id: "openrouter-model" } },
|
||||
@@ -502,7 +626,6 @@ describe("ProviderCatalog Phase 3.4 store-driven invalidation", () => {
|
||||
})
|
||||
|
||||
it("selection change does not invalidate model-list cache", async () => {
|
||||
const { createProviderCatalog } = await import("./catalog")
|
||||
mocks.resolveProviderConfig.mockResolvedValue({ modelId: "cached", knownModels: { cached: { id: "cached" } } })
|
||||
const providerId = parseProviderId("openrouter")
|
||||
const reader = makeReader({ providerId, apiKey: "same" })
|
||||
@@ -520,7 +643,6 @@ describe("ProviderCatalog Phase 3.4 store-driven invalidation", () => {
|
||||
|
||||
describe("ProviderCatalog Phase 3.5 listProviders", () => {
|
||||
it("returns SDK provider listings with top-level picker metadata", async () => {
|
||||
const { createProviderCatalog } = await import("./catalog")
|
||||
mocks.listLocalProviders.mockResolvedValue({
|
||||
providers: [
|
||||
{
|
||||
@@ -555,7 +677,6 @@ describe("ProviderCatalog Phase 3.5 listProviders", () => {
|
||||
})
|
||||
|
||||
it("caches provider listings per catalog instance without reading provider config", async () => {
|
||||
const { createProviderCatalog } = await import("./catalog")
|
||||
mocks.listLocalProviders.mockResolvedValue({
|
||||
providers: [
|
||||
{
|
||||
@@ -587,30 +708,167 @@ describe("ProviderCatalog Phase 3.5 listProviders", () => {
|
||||
expect(reader.readSelection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("refetches provider listings after explicit invalidation", async () => {
|
||||
const { createProviderCatalog } = await import("./catalog")
|
||||
it("invalidates provider listings after remote config changes", async () => {
|
||||
mocks.listLocalProviders.mockResolvedValue({
|
||||
providers: [
|
||||
{
|
||||
id: "openai-compatible",
|
||||
name: "OpenAI Compatible",
|
||||
protocol: "openai-chat",
|
||||
client: "openai-compatible",
|
||||
defaultModelId: "default",
|
||||
configFields: [{ path: "baseUrl", label: "Base URL", type: "url" }],
|
||||
source: "system",
|
||||
},
|
||||
],
|
||||
})
|
||||
mocks.setApiConfiguration({ openAiBaseUrl: "https://local.example/v1" })
|
||||
const providerId = parseProviderId("openai-compatible")
|
||||
const catalog = createProviderCatalog(makeReader({ providerId }))
|
||||
|
||||
const first = await catalog.listProviders()
|
||||
const cached = await catalog.listProviders()
|
||||
mocks.setRemoteConfigSettings({
|
||||
openAiBaseUrl: "https://remote.example/v1",
|
||||
})
|
||||
const refreshed = await catalog.listProviders()
|
||||
|
||||
expect(cached).toBe(first)
|
||||
expect(first[0]?.configValues).toEqual({ baseUrl: "https://local.example/v1" })
|
||||
expect(refreshed).not.toBe(first)
|
||||
expect(refreshed[0]?.configValues).toEqual({ baseUrl: "https://remote.example/v1" })
|
||||
expect(mocks.listLocalProviders).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("disables custom model ids in provider listings when remote config supplies a model allowlist", async () => {
|
||||
mocks.listLocalProviders.mockResolvedValue({
|
||||
providers: [
|
||||
{
|
||||
id: "openai-compatible",
|
||||
name: "OpenAI Compatible",
|
||||
protocol: "openai-chat",
|
||||
client: "openai-compatible",
|
||||
defaultModelId: "custom-model",
|
||||
source: "system",
|
||||
},
|
||||
],
|
||||
})
|
||||
mocks.setRemoteConfigSettings({
|
||||
remoteProviderModelSettings: {
|
||||
"openai-compatible": {
|
||||
models: [{ id: "allowed-model" }],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const listings = await createProviderCatalog(
|
||||
makeReader({ providerId: parseProviderId("openai-compatible") }),
|
||||
).listProviders()
|
||||
|
||||
expect(listings[0]?.allowsCustomModelIds).toBe(false)
|
||||
})
|
||||
|
||||
it("filters providers by SDK/core auth method rather than raw llms capabilities", async () => {
|
||||
mocks.listLocalProviders.mockResolvedValue({
|
||||
providers: [
|
||||
{
|
||||
id: "openai-codex-cli",
|
||||
name: "OpenAI Codex CLI",
|
||||
protocol: "responses",
|
||||
capabilities: ["local-auth"],
|
||||
authMethod: "local",
|
||||
source: "system",
|
||||
},
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
protocol: "responses",
|
||||
capabilities: ["oauth"],
|
||||
authMethod: "api-key",
|
||||
source: "system",
|
||||
},
|
||||
{
|
||||
id: "claude-code",
|
||||
name: "Claude Code",
|
||||
protocol: "messages",
|
||||
source: "system",
|
||||
},
|
||||
{
|
||||
id: "qwen-code",
|
||||
name: "Alibaba Qwen Code",
|
||||
protocol: "openai-chat",
|
||||
source: "system",
|
||||
},
|
||||
{
|
||||
id: "openai-codex",
|
||||
name: "OpenAI ChatGPT Subscription",
|
||||
protocol: "responses",
|
||||
capabilities: ["oauth"],
|
||||
authMethod: "oauth",
|
||||
source: "system",
|
||||
},
|
||||
{
|
||||
id: "deepseek",
|
||||
name: "DeepSeek",
|
||||
protocol: "openai-chat",
|
||||
source: "system",
|
||||
},
|
||||
],
|
||||
})
|
||||
const catalog = createProviderCatalog(makeReader({ providerId: parseProviderId("deepseek") }))
|
||||
|
||||
const listings = await catalog.listProviders()
|
||||
|
||||
expect(listings.map((provider) => provider.id)).toEqual([
|
||||
parseProviderId("opencode"),
|
||||
parseProviderId("openai-codex"),
|
||||
parseProviderId("deepseek"),
|
||||
])
|
||||
})
|
||||
|
||||
it("invalidates provider listings after provider settings change", async () => {
|
||||
mocks.listLocalProviders
|
||||
.mockResolvedValueOnce({
|
||||
providers: [{ id: "cline", name: "Cline", protocol: "anthropic", client: "anthropic", source: "system" }],
|
||||
providers: [
|
||||
{
|
||||
id: "ollama",
|
||||
name: "Ollama",
|
||||
protocol: "openai-chat",
|
||||
client: "openai-compatible",
|
||||
defaultModelId: "default-a",
|
||||
source: "system",
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
providers: [
|
||||
{ id: "cline-pass", name: "ClinePass", protocol: "anthropic", client: "anthropic", source: "system" },
|
||||
{
|
||||
id: "ollama",
|
||||
name: "Ollama",
|
||||
protocol: "openai-chat",
|
||||
client: "openai-compatible",
|
||||
defaultModelId: "default-b",
|
||||
source: "system",
|
||||
},
|
||||
],
|
||||
})
|
||||
const catalog = createProviderCatalog(makeReader({ providerId: parseProviderId("cline") }))
|
||||
const providerId = parseProviderId("ollama")
|
||||
const reader = makeReader({ providerId, baseUrl: "http://old.example" })
|
||||
const catalog = createProviderCatalog(reader)
|
||||
|
||||
const first = await catalog.listProviders()
|
||||
catalog.invalidateProviderListings()
|
||||
const second = await catalog.listProviders()
|
||||
const cached = await catalog.listProviders()
|
||||
reader.setConfig({ providerId, baseUrl: "http://new.example" })
|
||||
reader.emit({ kind: "fields", providerId, config: { providerId, baseUrl: "http://new.example" } })
|
||||
const refreshed = await catalog.listProviders()
|
||||
|
||||
expect(first[0]?.id).toBe("cline")
|
||||
expect(second[0]?.id).toBe("cline-pass")
|
||||
expect(cached).toBe(first)
|
||||
expect(first[0]?.defaultModelId).toBe("default-a")
|
||||
expect(refreshed[0]?.defaultModelId).toBe("default-b")
|
||||
expect(mocks.listLocalProviders).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("retries provider listing after an SDK listing failure", async () => {
|
||||
const { createProviderCatalog } = await import("./catalog")
|
||||
mocks.listLocalProviders.mockRejectedValueOnce(new Error("temporary catalog failure")).mockResolvedValueOnce({
|
||||
providers: [
|
||||
{
|
||||
@@ -641,7 +899,6 @@ describe("ProviderCatalog Phase 3.5 listProviders", () => {
|
||||
|
||||
describe("ProviderCatalog Phase 3.6 subscribe", () => {
|
||||
it("fires the provider listener after resolveModels completes", async () => {
|
||||
const { createProviderCatalog } = await import("./catalog")
|
||||
mocks.resolveProviderConfig.mockResolvedValue({ modelId: "model-a", knownModels: { "model-a": { id: "model-a" } } })
|
||||
const providerId = parseProviderId("deepseek")
|
||||
const catalog = createProviderCatalog(makeReader({ providerId, apiKey: "key" }))
|
||||
@@ -655,7 +912,6 @@ describe("ProviderCatalog Phase 3.6 subscribe", () => {
|
||||
})
|
||||
|
||||
it("fires after a cache-hit resolveModels result", async () => {
|
||||
const { createProviderCatalog } = await import("./catalog")
|
||||
mocks.resolveProviderConfig.mockResolvedValue({ modelId: "cached", knownModels: { cached: { id: "cached" } } })
|
||||
const providerId = parseProviderId("openrouter")
|
||||
const catalog = createProviderCatalog(makeReader({ providerId, apiKey: "same" }))
|
||||
@@ -673,7 +929,6 @@ describe("ProviderCatalog Phase 3.6 subscribe", () => {
|
||||
})
|
||||
|
||||
it("does not fire provider listener for another provider", async () => {
|
||||
const { createProviderCatalog } = await import("./catalog")
|
||||
mocks.resolveProviderConfig.mockResolvedValue({ modelId: "model-a", knownModels: { "model-a": { id: "model-a" } } })
|
||||
const subscribedProvider = parseProviderId("deepseek")
|
||||
const resolvedProvider = parseProviderId("openrouter")
|
||||
@@ -687,7 +942,6 @@ describe("ProviderCatalog Phase 3.6 subscribe", () => {
|
||||
})
|
||||
|
||||
it("does not fire model-list listener when only commitSelection happens", async () => {
|
||||
const { createProviderCatalog } = await import("./catalog")
|
||||
const providerId = parseProviderId("ollama")
|
||||
const reader = makeReader({ providerId, baseUrl: "http://localhost:11434/v1" })
|
||||
const catalog = createProviderCatalog(reader)
|
||||
@@ -701,7 +955,6 @@ describe("ProviderCatalog Phase 3.6 subscribe", () => {
|
||||
})
|
||||
|
||||
it("disposable unregisters the provider listener", async () => {
|
||||
const { createProviderCatalog } = await import("./catalog")
|
||||
mocks.resolveProviderConfig.mockResolvedValue({ modelId: "model-a", knownModels: { "model-a": { id: "model-a" } } })
|
||||
const providerId = parseProviderId("deepseek")
|
||||
const catalog = createProviderCatalog(makeReader({ providerId, apiKey: "key" }))
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { listLocalProviders, type ModelCatalogConfig, resolveProviderConfig } from "@cline/core"
|
||||
import { type ProviderConfig, resolveProviderUsageCostDisplay } from "@cline/llms"
|
||||
import { type ProviderListItem } from "@cline/shared"
|
||||
import { type ModelInfo, openAiModelInfoSafeDefaults } from "@shared/api"
|
||||
import { isVscodeUnsupportedProvider } from "@shared/model-catalog/provider-helpers"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { getFeatureFlagsService } from "@/services/feature-flags"
|
||||
import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
|
||||
import type {
|
||||
RemoteBedrockCustomModelEntry,
|
||||
RemoteProviderModelEntry,
|
||||
RemoteProviderModelSettings,
|
||||
} from "@/shared/storage/state-keys"
|
||||
import { getProviderSettingsManager } from "../provider-migration"
|
||||
import type {
|
||||
CatalogError,
|
||||
@@ -18,7 +26,7 @@ import type {
|
||||
ProviderModelsResult,
|
||||
UsageCostDisplay,
|
||||
} from "./contracts"
|
||||
import { providerAllowsCustomModelIds } from "./custom-model-ids"
|
||||
import { buildEffectiveProviderConfig } from "./effective-config"
|
||||
import { computeConfigFingerprint } from "./fingerprint"
|
||||
import { applyHostModelInfoOverrides } from "./host-overrides"
|
||||
import { parseProviderId } from "./provider-id"
|
||||
@@ -41,7 +49,6 @@ interface ResolveRecordOptions {
|
||||
load(): Promise<ProviderModelsRecord>
|
||||
}
|
||||
|
||||
const DEFAULT_MODEL_CACHE_TTL_MS = 5 * 60 * 1000
|
||||
const DEFAULT_MODEL_CATALOG_CONFIG: ModelCatalogConfig = {
|
||||
loadLatestOnInit: true,
|
||||
loadPrivateOnAuth: true,
|
||||
@@ -49,6 +56,31 @@ const DEFAULT_MODEL_CATALOG_CONFIG: ModelCatalogConfig = {
|
||||
cacheTtlMs: 0,
|
||||
}
|
||||
|
||||
// Providers whose model id is user-supplied free text rather than a fixed
|
||||
// catalog selection. The SDK catalog for these either has no curated model
|
||||
// list (openai-compatible: bring-your-own base URL + model) or a host-fetched
|
||||
// list that the user can also bypass (ollama/lmstudio/litellm). For these,
|
||||
// the picker must allow arbitrary model ids and model resolution must honor
|
||||
// the requested id instead of coercing to the catalog default.
|
||||
const CUSTOM_MODEL_ID_PROVIDER_IDS = new Set(["openai-compatible", "ollama", "lmstudio", "litellm", "bedrock"])
|
||||
|
||||
/**
|
||||
* Whether a provider id accepts a user-supplied (custom) model id. Exported so
|
||||
* model-resolution code paths (e.g. `resolveModelInfo`) can honor a custom id
|
||||
* for these providers rather than falling back to the SDK catalog default.
|
||||
*
|
||||
* Accepts either the extension or SDK provider id spelling (the extension's
|
||||
* `openai` maps to the SDK's `openai-compatible`).
|
||||
*/
|
||||
export function providerAllowsCustomModelIds(providerId: string): boolean {
|
||||
const parsedProviderId = parseProviderId(providerId)
|
||||
return CUSTOM_MODEL_ID_PROVIDER_IDS.has(toSdkProviderId(providerId)) && !hasRemoteModelAllowlist(parsedProviderId)
|
||||
}
|
||||
|
||||
export function providerHasRemoteModelAllowlist(providerId: string): boolean {
|
||||
return hasRemoteModelAllowlist(parseProviderId(providerId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the SDK's usage-cost-display answer (string union) into the
|
||||
* extension's {@link UsageCostDisplay} type. The SDK function takes a
|
||||
@@ -63,6 +95,60 @@ function makeCacheKey(providerId: ProviderId, fingerprint: Fingerprint): CacheKe
|
||||
return `${providerId}:${fingerprint}`
|
||||
}
|
||||
|
||||
function isSdkAwsAuthentication(value: unknown): value is NonNullable<ProviderConfig["aws"]>["authentication"] {
|
||||
return value === "iam" || value === "api-key" || value === "apikey" || value === "profile"
|
||||
}
|
||||
|
||||
function normalizeSdkAwsAuthentication(value: unknown): NonNullable<ProviderConfig["aws"]>["authentication"] | undefined {
|
||||
if (value === "credentials") {
|
||||
return "iam"
|
||||
}
|
||||
return isSdkAwsAuthentication(value) ? value : undefined
|
||||
}
|
||||
|
||||
function toSdkAwsConfig(config: EffectiveProviderConfig["aws"]): ProviderConfig["aws"] {
|
||||
if (!config) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
authentication: normalizeSdkAwsAuthentication(config.authentication),
|
||||
}
|
||||
}
|
||||
|
||||
function isBedrockApiKeyAuthentication(authentication: unknown): boolean {
|
||||
return authentication === "api-key" || authentication === "apikey"
|
||||
}
|
||||
|
||||
function isSdkSapApi(value: unknown): value is NonNullable<ProviderConfig["sap"]>["api"] {
|
||||
return value === "orchestration" || value === "foundation-models"
|
||||
}
|
||||
|
||||
function toSdkSapConfig(config: EffectiveProviderConfig["sap"]): ProviderConfig["sap"] {
|
||||
if (!config) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
api: isSdkSapApi(config.api) ? config.api : undefined,
|
||||
defaultSettings: config.defaultSettings ? { ...config.defaultSettings } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function isSdkOcaMode(value: unknown): value is NonNullable<ProviderConfig["oca"]>["mode"] {
|
||||
return value === "internal" || value === "external"
|
||||
}
|
||||
|
||||
function toSdkOcaConfig(config: EffectiveProviderConfig["oca"]): ProviderConfig["oca"] {
|
||||
if (!config) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
mode: isSdkOcaMode(config.mode) ? config.mode : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function assertRecordMatchesRequest(record: ProviderModelsRecord, providerId: ProviderId, fingerprint: Fingerprint): void {
|
||||
if (record.providerId !== providerId || record.configFingerprint !== fingerprint) {
|
||||
throw new Error(
|
||||
@@ -143,18 +229,124 @@ function createProviderModelsCache(options: ProviderModelsCacheOptions) {
|
||||
}
|
||||
|
||||
function toSdkProviderConfig(config: EffectiveProviderConfig, selection: ModelSelection | undefined): ProviderConfig {
|
||||
const providerId = toSdkProviderId(config.providerId)
|
||||
const aws = toSdkAwsConfig(config.aws)
|
||||
const apiKey = providerId === "bedrock" && !isBedrockApiKeyAuthentication(aws?.authentication) ? undefined : config.apiKey
|
||||
|
||||
return {
|
||||
providerId: toSdkProviderId(config.providerId),
|
||||
providerId,
|
||||
modelId: selection?.modelId ?? "",
|
||||
apiKey: config.apiKey,
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
baseUrl: config.baseUrl,
|
||||
headers: config.headers ? { ...config.headers } : undefined,
|
||||
accessToken: config.auth?.accessToken,
|
||||
refreshToken: config.auth?.refreshToken,
|
||||
accountId: config.auth?.accountId,
|
||||
apiLine: config.apiLine === "china" || config.apiLine === "international" ? config.apiLine : undefined,
|
||||
region: config.gcp?.region ?? config.region,
|
||||
region: config.gcp?.region ?? config.aws?.region ?? config.region,
|
||||
gcp: config.gcp ? { ...config.gcp } : undefined,
|
||||
azure: config.azure ? { ...config.azure } : undefined,
|
||||
aws,
|
||||
sap: toSdkSapConfig(config.sap),
|
||||
oca: toSdkOcaConfig(config.oca),
|
||||
}
|
||||
}
|
||||
|
||||
function readRemoteProviderModelSettings(providerId: ProviderId): RemoteProviderModelSettings[string] | undefined {
|
||||
try {
|
||||
const manager = StateManager.get() as { getRemoteConfigSettings?: () => unknown }
|
||||
const settings = manager.getRemoteConfigSettings?.()
|
||||
if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
|
||||
return undefined
|
||||
}
|
||||
const remoteProviderModelSettings = (settings as { remoteProviderModelSettings?: RemoteProviderModelSettings })
|
||||
.remoteProviderModelSettings
|
||||
return remoteProviderModelSettings?.[toSdkProviderId(providerId)] ?? remoteProviderModelSettings?.[providerId]
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function readRemoteAllowedModelIdsForProvider(providerId: ProviderId): readonly string[] {
|
||||
const settings = readRemoteProviderModelSettings(providerId)
|
||||
return [
|
||||
...(settings?.models ?? []).map((model) => model.id),
|
||||
...(settings?.bedrockCustomModels ?? []).map((model) => model.name),
|
||||
].filter((modelId) => modelId.trim().length > 0)
|
||||
}
|
||||
|
||||
export function readRemoteAllowedModelIds(providerId: string): readonly string[] {
|
||||
return readRemoteAllowedModelIdsForProvider(parseProviderId(providerId))
|
||||
}
|
||||
|
||||
function hasRemoteModelAllowlist(providerId: ProviderId): boolean {
|
||||
return readRemoteAllowedModelIdsForProvider(providerId).length > 0
|
||||
}
|
||||
|
||||
function readRemoteConfigCacheKey(): string {
|
||||
try {
|
||||
const manager = StateManager.get() as { getRemoteConfigSettings?: () => unknown }
|
||||
return JSON.stringify(manager.getRemoteConfigSettings?.() ?? {}) ?? "{}"
|
||||
} catch {
|
||||
return "{}"
|
||||
}
|
||||
}
|
||||
|
||||
function withRemoteModelInfo(base: ModelInfo | undefined, entry: RemoteProviderModelEntry): ModelInfo {
|
||||
const next: ModelInfo & { isR1FormatRequired?: boolean } = {
|
||||
...(base ?? openAiModelInfoSafeDefaults),
|
||||
name: base?.name ?? entry.id,
|
||||
}
|
||||
if (entry.contextWindow !== undefined) next.contextWindow = entry.contextWindow
|
||||
if (entry.maxTokens !== undefined) next.maxTokens = entry.maxTokens
|
||||
if (entry.inputPrice !== undefined) next.inputPrice = entry.inputPrice
|
||||
if (entry.outputPrice !== undefined) next.outputPrice = entry.outputPrice
|
||||
if (entry.supportsImages !== undefined) next.supportsImages = entry.supportsImages
|
||||
if (entry.promptCachingEnabled !== undefined) next.supportsPromptCache = entry.promptCachingEnabled
|
||||
if (entry.temperature !== undefined) next.temperature = entry.temperature
|
||||
if (entry.isR1FormatRequired !== undefined) next.isR1FormatRequired = entry.isR1FormatRequired
|
||||
if (entry.thinkingBudgetTokens !== undefined) {
|
||||
next.thinkingConfig = { ...(next.thinkingConfig ?? {}), maxBudget: entry.thinkingBudgetTokens }
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function withRemoteBedrockCustomModelInfo(
|
||||
models: ReadonlyMap<string, ModelInfo>,
|
||||
entry: RemoteBedrockCustomModelEntry,
|
||||
): ModelInfo {
|
||||
const base = models.get(entry.baseModelId) ?? openAiModelInfoSafeDefaults
|
||||
return {
|
||||
...base,
|
||||
name: entry.name,
|
||||
description: base.description
|
||||
? `${base.description} Base model: ${entry.baseModelId}.`
|
||||
: `Base model: ${entry.baseModelId}.`,
|
||||
...(entry.thinkingBudgetTokens !== undefined
|
||||
? { thinkingConfig: { ...(base.thinkingConfig ?? {}), maxBudget: entry.thinkingBudgetTokens } }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
function applyRemoteModelSettings(record: ProviderModelsRecord, providerId: ProviderId): ProviderModelsRecord {
|
||||
const settings = readRemoteProviderModelSettings(providerId)
|
||||
if (!settings?.models?.length && !settings?.bedrockCustomModels?.length) {
|
||||
return record
|
||||
}
|
||||
|
||||
const nextModels = new Map<string, ModelInfo>()
|
||||
for (const entry of settings.models ?? []) {
|
||||
nextModels.set(entry.id, withRemoteModelInfo(record.models.get(entry.id), entry))
|
||||
}
|
||||
for (const customModel of settings.bedrockCustomModels ?? []) {
|
||||
nextModels.set(customModel.name, withRemoteBedrockCustomModelInfo(record.models, customModel))
|
||||
}
|
||||
|
||||
return {
|
||||
...record,
|
||||
models: nextModels,
|
||||
defaultModelId: nextModels.has(record.defaultModelId) ? record.defaultModelId : (nextModels.keys().next().value ?? ""),
|
||||
source: "host-adapter",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,33 +362,73 @@ function optionalNonEmpty(value: string | undefined): string | undefined {
|
||||
return trimmed ? trimmed : undefined
|
||||
}
|
||||
|
||||
function isProviderConfigFieldValue(value: unknown): boolean {
|
||||
return typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value === null
|
||||
}
|
||||
|
||||
function readConfigPathValue(config: EffectiveProviderConfig, path: string): unknown {
|
||||
let current: unknown = config
|
||||
for (const segment of path.split(".")) {
|
||||
if (typeof current !== "object" || current === null || Array.isArray(current)) {
|
||||
return undefined
|
||||
}
|
||||
current = (current as Record<string, unknown>)[segment]
|
||||
}
|
||||
return isProviderConfigFieldValue(current) || path === "headers" ? current : undefined
|
||||
}
|
||||
|
||||
function readEffectiveConfigValues(provider: ProviderListItem): Record<string, unknown> | undefined {
|
||||
const fields = provider.configFields?.filter((field) => field.path && !field.secret)
|
||||
if (!fields?.length) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const effectiveConfig = buildEffectiveProviderConfig(parseProviderId(provider.id))
|
||||
const values: Record<string, unknown> = {}
|
||||
for (const field of fields) {
|
||||
const value = readConfigPathValue(effectiveConfig, field.path)
|
||||
if (value !== undefined) {
|
||||
values[field.path] = value
|
||||
}
|
||||
}
|
||||
return Object.keys(values).length > 0 ? values : undefined
|
||||
}
|
||||
|
||||
function toProviderListing(provider: ProviderListItem): ProviderListing {
|
||||
const effectiveConfigValues = readEffectiveConfigValues(provider)
|
||||
const providerId = parseProviderId(provider.id)
|
||||
return {
|
||||
id: parseProviderId(provider.id),
|
||||
id: providerId,
|
||||
name: provider.name,
|
||||
defaultModelId: optionalNonEmpty(provider.defaultModelId),
|
||||
protocol: provider.protocol,
|
||||
// ProviderListing intentionally does not include full model-list data.
|
||||
// Reuse the lightweight description slot until the RPC-facing picker
|
||||
// contract decides whether it needs a generic provider description field.
|
||||
authMethod: provider.authMethod,
|
||||
authDescription: optionalNonEmpty(provider.authDescription),
|
||||
// The SDK has the right signal for this on each provider (e.g.
|
||||
// `modelsSourceUrl` for ollama/lmstudio, or the `openai-compatible`
|
||||
// family with no curated catalog), but does not yet expose it
|
||||
// through a public helper. Until then this set is the host-side
|
||||
// fallback; remove it as soon as upstream exposes the signal.
|
||||
allowsCustomModelIds: providerAllowsCustomModelIds(provider.id),
|
||||
baseUrlDescription: optionalNonEmpty(provider.baseUrlDescription),
|
||||
configFields: provider.configFields,
|
||||
configValues: effectiveConfigValues
|
||||
? { ...(provider.configValues ?? {}), ...effectiveConfigValues }
|
||||
: provider.configValues,
|
||||
allowsCustomModelIds: CUSTOM_MODEL_ID_PROVIDER_IDS.has(providerId) && !hasRemoteModelAllowlist(providerId),
|
||||
usageCostDisplay: readUsageCostDisplay(provider.id),
|
||||
}
|
||||
}
|
||||
|
||||
async function listSdkProviderListings(): Promise<ReadonlyArray<ProviderListing>> {
|
||||
function isVscodeSupportedProvider(provider: ProviderListItem): boolean {
|
||||
if (isVscodeUnsupportedProvider(provider.id)) {
|
||||
return false
|
||||
}
|
||||
if (provider.authMethod === "local") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function listSdkProviderListings(): Promise<ReadonlyArray<ProviderListing>> {
|
||||
const manager = getProviderSettingsManager()
|
||||
const featureFlags = getFeatureFlagsService()
|
||||
const { providers } = await listLocalProviders(manager, {
|
||||
isClinePassEnabled: featureFlags.getBooleanFlagEnabled(FeatureFlag.CLINE_PASS),
|
||||
})
|
||||
return providers.map(toProviderListing)
|
||||
return listLocalProviders(manager, {
|
||||
isClinePassEnabled: getFeatureFlagsService().getBooleanFlagEnabled(FeatureFlag.CLINE_PASS),
|
||||
}).then(({ providers }) => providers.filter(isVscodeSupportedProvider).map(toProviderListing))
|
||||
}
|
||||
|
||||
async function resolveSdkModels(
|
||||
@@ -249,16 +481,6 @@ function toCatalogError(error: unknown): CatalogError {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal test hook for cache/in-flight behavior. Not part of the public
|
||||
* model-catalog API; production callers should use createProviderCatalog.
|
||||
*/
|
||||
export const _testing = {
|
||||
createProviderModelsCache,
|
||||
makeCacheKey,
|
||||
assertRecordMatchesRequest,
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a {@link ProviderCatalog}.
|
||||
*
|
||||
@@ -268,8 +490,9 @@ export const _testing = {
|
||||
*/
|
||||
export function createProviderCatalog(reader: ProviderConfigReader): ProviderCatalog {
|
||||
const now = () => Date.now()
|
||||
const cache = createProviderModelsCache({ ttlMs: DEFAULT_MODEL_CACHE_TTL_MS, now })
|
||||
const cache = createProviderModelsCache({ ttlMs: 5 * 60 * 1000, now })
|
||||
let providerListingsPromise: Promise<ReadonlyArray<ProviderListing>> | undefined
|
||||
let providerListingsRemoteConfigKey: string | undefined
|
||||
const modelListeners = new Map<ProviderId, Set<(event: ProviderModelsEvent) => void>>()
|
||||
|
||||
function notifyModelListeners(providerId: ProviderId, result: ProviderModelsResult): void {
|
||||
@@ -287,23 +510,25 @@ export function createProviderCatalog(reader: ProviderConfigReader): ProviderCat
|
||||
if (event.kind !== "fields") {
|
||||
return
|
||||
}
|
||||
providerListingsPromise = undefined
|
||||
const latestConfig = reader.read(event.providerId)
|
||||
const latestFingerprint = computeConfigFingerprint(event.providerId, latestConfig)
|
||||
cache.invalidateProviderExcept(event.providerId, latestFingerprint)
|
||||
})
|
||||
return {
|
||||
async listProviders(): Promise<ReadonlyArray<ProviderListing>> {
|
||||
providerListingsPromise ??= listSdkProviderListings().catch((error) => {
|
||||
providerListingsPromise = undefined
|
||||
throw error
|
||||
})
|
||||
const remoteConfigKey = readRemoteConfigCacheKey()
|
||||
if (!providerListingsPromise || providerListingsRemoteConfigKey !== remoteConfigKey) {
|
||||
providerListingsRemoteConfigKey = remoteConfigKey
|
||||
providerListingsPromise = listSdkProviderListings().catch((error) => {
|
||||
providerListingsPromise = undefined
|
||||
providerListingsRemoteConfigKey = undefined
|
||||
throw error
|
||||
})
|
||||
}
|
||||
return providerListingsPromise
|
||||
},
|
||||
|
||||
invalidateProviderListings(): void {
|
||||
providerListingsPromise = undefined
|
||||
},
|
||||
|
||||
async resolveModels(
|
||||
providerId: ProviderId,
|
||||
options?: { readonly forceRefresh?: boolean },
|
||||
@@ -322,6 +547,7 @@ export function createProviderCatalog(reader: ProviderConfigReader): ProviderCat
|
||||
forceRefresh: options?.forceRefresh,
|
||||
load: () => resolveSdkModels(providerId, fingerprint, config, selection, now),
|
||||
})
|
||||
result = applyRemoteModelSettings(result, providerId)
|
||||
} catch (error) {
|
||||
result = {
|
||||
ok: false,
|
||||
@@ -338,7 +564,8 @@ export function createProviderCatalog(reader: ProviderConfigReader): ProviderCat
|
||||
peekModels(providerId: ProviderId): ProviderModelsResult | undefined {
|
||||
const config = reader.read(providerId)
|
||||
const fingerprint = computeConfigFingerprint(providerId, config)
|
||||
return cache.peek(providerId, fingerprint)
|
||||
const result = cache.peek(providerId, fingerprint)
|
||||
return result ? applyRemoteModelSettings(result, providerId) : undefined
|
||||
},
|
||||
|
||||
subscribe(providerId: ProviderId, listener: (event: ProviderModelsEvent) => void): Disposable {
|
||||
@@ -359,3 +586,13 @@ export function createProviderCatalog(reader: ProviderConfigReader): ProviderCat
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal test hook for cache/in-flight behavior. Not part of the public
|
||||
* model-catalog API; production callers should use createProviderCatalog.
|
||||
*/
|
||||
export const _testing = {
|
||||
createProviderModelsCache,
|
||||
makeCacheKey,
|
||||
assertRecordMatchesRequest,
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* those casts are never necessary or correct.
|
||||
*/
|
||||
|
||||
import type { ProviderConfigField } from "@cline/shared"
|
||||
import type { ModelInfo } from "@shared/api"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
|
||||
@@ -75,6 +76,7 @@ export interface AwsProviderConfig {
|
||||
readonly accessKey?: string
|
||||
readonly secretKey?: string
|
||||
readonly sessionToken?: string
|
||||
readonly region?: string
|
||||
readonly authentication?: "iam" | "api-key" | "apikey" | "profile" | string
|
||||
readonly profile?: string
|
||||
readonly usePromptCache?: boolean
|
||||
@@ -89,6 +91,26 @@ export interface GcpProviderConfig {
|
||||
readonly region?: string
|
||||
}
|
||||
|
||||
export interface AzureProviderConfig {
|
||||
readonly apiVersion?: string
|
||||
}
|
||||
|
||||
export interface SapProviderConfig {
|
||||
readonly clientId?: string
|
||||
readonly clientSecret?: string
|
||||
readonly tokenUrl?: string
|
||||
readonly resourceGroup?: string
|
||||
readonly deploymentId?: string
|
||||
readonly useOrchestrationMode?: boolean
|
||||
readonly api?: "orchestration" | "foundation-models" | string
|
||||
readonly defaultSettings?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
|
||||
export interface OcaProviderConfig {
|
||||
readonly mode?: "internal" | "external" | string
|
||||
readonly usePromptCache?: boolean
|
||||
}
|
||||
|
||||
export interface EffectiveProviderConfig {
|
||||
readonly providerId: ProviderId
|
||||
readonly apiKey?: string
|
||||
@@ -98,6 +120,9 @@ export interface EffectiveProviderConfig {
|
||||
readonly region?: string
|
||||
readonly aws?: AwsProviderConfig
|
||||
readonly gcp?: GcpProviderConfig
|
||||
readonly azure?: AzureProviderConfig
|
||||
readonly sap?: SapProviderConfig
|
||||
readonly oca?: OcaProviderConfig
|
||||
/**
|
||||
* OAuth-style auth bundle (e.g. cline provider's WorkOS token).
|
||||
* Compatible with `apiKey`; some providers populate both.
|
||||
@@ -133,6 +158,8 @@ interface ProviderReasoningPatch {
|
||||
}
|
||||
|
||||
export interface ProviderConfigPatch {
|
||||
readonly mode?: Mode
|
||||
readonly settings?: Readonly<Record<string, unknown>>
|
||||
readonly apiKey?: string | null
|
||||
readonly baseUrl?: string | null
|
||||
readonly apiLine?: string | null
|
||||
@@ -140,6 +167,9 @@ export interface ProviderConfigPatch {
|
||||
readonly region?: string | null
|
||||
readonly aws?: AwsProviderConfig | null
|
||||
readonly gcp?: GcpProviderConfig | null
|
||||
readonly azure?: AzureProviderConfig | null
|
||||
readonly sap?: SapProviderConfig | null
|
||||
readonly oca?: OcaProviderConfig | null
|
||||
readonly auth?: {
|
||||
readonly accessToken?: string
|
||||
readonly refreshToken?: string
|
||||
@@ -226,8 +256,11 @@ export interface ProviderListing {
|
||||
readonly defaultModelId?: string
|
||||
readonly family?: string
|
||||
readonly protocol?: string
|
||||
readonly authMethod?: "api-key" | "oauth" | "local"
|
||||
readonly authDescription?: string
|
||||
readonly baseUrlDescription?: string
|
||||
readonly configFields?: readonly ProviderConfigField[]
|
||||
readonly configValues?: Readonly<Record<string, unknown>>
|
||||
/**
|
||||
* Whether arbitrary model ids are meaningful for this provider. Drives
|
||||
* the manual-entry affordance in `ModelPickerWithManualEntry`.
|
||||
@@ -384,12 +417,6 @@ export interface ProviderCatalog {
|
||||
*/
|
||||
listProviders(): Promise<ReadonlyArray<ProviderListing>>
|
||||
|
||||
/**
|
||||
* Clear cached provider listings so the next list request re-applies
|
||||
* feature-flag-gated provider visibility.
|
||||
*/
|
||||
invalidateProviderListings(): void
|
||||
|
||||
/**
|
||||
* Resolve models for a provider given the current effective config.
|
||||
* Reads effective config from the store; callers do not pass it.
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { toSdkProviderId } from "./sdk-provider-id"
|
||||
|
||||
// Providers whose model id is user-supplied free text rather than a fixed
|
||||
// catalog selection. The SDK catalog for these either has no curated model
|
||||
// list (openai-compatible: bring-your-own base URL + model) or a host-fetched
|
||||
// list that the user can also bypass (ollama/lmstudio/litellm). For these,
|
||||
// the picker must allow arbitrary model ids and model resolution must honor
|
||||
// the requested id instead of coercing to the catalog default.
|
||||
const CUSTOM_MODEL_ID_PROVIDER_IDS = new Set(["openai-compatible", "ollama", "lmstudio", "litellm"])
|
||||
|
||||
/**
|
||||
* Whether a provider id accepts a user-supplied (custom) model id.
|
||||
*
|
||||
* Keep this helper in a side-effect-free module so lightweight controller
|
||||
* handlers (e.g. `resolveModelInfo`) do not import the full provider catalog
|
||||
* implementation and its feature-flag/controller dependency graph.
|
||||
*
|
||||
* Accepts either the extension or SDK provider id spelling (the extension's
|
||||
* `openai` maps to the SDK's `openai-compatible`).
|
||||
*/
|
||||
export function providerAllowsCustomModelIds(providerId: string): boolean {
|
||||
return CUSTOM_MODEL_ID_PROVIDER_IDS.has(toSdkProviderId(providerId))
|
||||
}
|
||||
@@ -4,17 +4,24 @@ import { parseProviderId } from "./provider-id"
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
let apiConfiguration: ApiConfiguration = {}
|
||||
let remoteConfigSettings: ApiConfiguration = {}
|
||||
let providerSettingsById: Record<string, unknown> = {}
|
||||
|
||||
return {
|
||||
setApiConfiguration(value: ApiConfiguration): void {
|
||||
apiConfiguration = value
|
||||
},
|
||||
setRemoteConfigSettings(value: ApiConfiguration): void {
|
||||
remoteConfigSettings = value
|
||||
},
|
||||
setProviderSettings(value: Record<string, unknown>): void {
|
||||
providerSettingsById = value
|
||||
},
|
||||
getStateManager() {
|
||||
return { getApiConfiguration: () => apiConfiguration }
|
||||
return {
|
||||
getApiConfiguration: () => apiConfiguration,
|
||||
getRemoteConfigSettings: () => remoteConfigSettings,
|
||||
}
|
||||
},
|
||||
getProviderSettingsManager() {
|
||||
return { getProviderSettings: (providerId: string) => providerSettingsById[providerId] }
|
||||
@@ -33,10 +40,11 @@ vi.mock("../provider-migration", () => ({
|
||||
describe("buildEffectiveProviderConfig", () => {
|
||||
beforeEach(() => {
|
||||
mocks.setApiConfiguration({})
|
||||
mocks.setRemoteConfigSettings({})
|
||||
mocks.setProviderSettings({})
|
||||
})
|
||||
|
||||
it("builds Ollama config with StateManager base URL over providers.json and local extras", async () => {
|
||||
it("builds Ollama config from SDK provider settings when a provider record exists", async () => {
|
||||
const { buildEffectiveProviderConfig } = await import("./effective-config")
|
||||
mocks.setProviderSettings({
|
||||
ollama: {
|
||||
@@ -53,12 +61,11 @@ describe("buildEffectiveProviderConfig", () => {
|
||||
expect(buildEffectiveProviderConfig(parseProviderId("ollama"))).toEqual({
|
||||
providerId: parseProviderId("ollama"),
|
||||
apiKey: "provider-ollama-key",
|
||||
baseUrl: "http://state-ollama:11434",
|
||||
extras: { ollamaApiOptionsCtxNum: "8192" },
|
||||
baseUrl: "http://provider-ollama:11434",
|
||||
})
|
||||
})
|
||||
|
||||
it("builds LiteLLM config by merging providers.json fields and StateManager overlays", async () => {
|
||||
it("builds LiteLLM config from SDK provider settings without StateManager overlays", async () => {
|
||||
const { buildEffectiveProviderConfig } = await import("./effective-config")
|
||||
mocks.setProviderSettings({
|
||||
litellm: {
|
||||
@@ -77,72 +84,72 @@ describe("buildEffectiveProviderConfig", () => {
|
||||
expect(buildEffectiveProviderConfig(parseProviderId("litellm"))).toEqual({
|
||||
providerId: parseProviderId("litellm"),
|
||||
apiKey: "provider-litellm-key",
|
||||
baseUrl: "https://state-litellm.example.com/v1",
|
||||
baseUrl: "https://provider-litellm.example.com/v1",
|
||||
headers: { "x-provider": "provider-header" },
|
||||
extras: { providerOnly: true, liteLlmUsePromptCache: true },
|
||||
extras: { providerOnly: true },
|
||||
})
|
||||
})
|
||||
|
||||
it("uses StateManager DeepSeek API key over providers.json", async () => {
|
||||
it("uses SDK provider settings over stale StateManager DeepSeek keys", async () => {
|
||||
const { buildEffectiveProviderConfig } = await import("./effective-config")
|
||||
mocks.setProviderSettings({ deepseek: { provider: "deepseek", apiKey: "provider-deepseek-key" } })
|
||||
mocks.setApiConfiguration({ deepSeekApiKey: "state-deepseek-key" })
|
||||
|
||||
expect(buildEffectiveProviderConfig(parseProviderId("deepseek"))).toEqual({
|
||||
providerId: parseProviderId("deepseek"),
|
||||
apiKey: "state-deepseek-key",
|
||||
apiKey: "provider-deepseek-key",
|
||||
})
|
||||
})
|
||||
|
||||
it("reads normalized nousResearch API key from StateManager", async () => {
|
||||
it("reads normalized nousResearch API key from SDK provider settings", async () => {
|
||||
const { buildEffectiveProviderConfig } = await import("./effective-config")
|
||||
mocks.setProviderSettings({ nousresearch: { provider: "nousresearch", apiKey: "provider-nous-key" } })
|
||||
mocks.setApiConfiguration({ nousResearchApiKey: "state-nous-key" })
|
||||
|
||||
expect(buildEffectiveProviderConfig(parseProviderId("nousResearch"))).toEqual({
|
||||
providerId: parseProviderId("nousResearch"),
|
||||
apiKey: "state-nous-key",
|
||||
apiKey: "provider-nous-key",
|
||||
})
|
||||
})
|
||||
|
||||
it("carries Qwen apiLine from StateManager effective configuration", async () => {
|
||||
it("hydrates OpenAI-compatible SDK config from provider settings", async () => {
|
||||
const { buildEffectiveProviderConfig } = await import("./effective-config")
|
||||
mocks.setProviderSettings({
|
||||
"openai-compatible": {
|
||||
provider: "openai-compatible",
|
||||
apiKey: "provider-openai-key",
|
||||
baseUrl: "https://provider.example/v1",
|
||||
azure: { apiVersion: "2024-02-15-preview" },
|
||||
},
|
||||
})
|
||||
mocks.setApiConfiguration({
|
||||
openAiApiKey: "state-openai-key",
|
||||
openAiBaseUrl: "https://state.example/v1",
|
||||
openAiHeaders: { "x-provider": "state" },
|
||||
azureApiVersion: "2025-01-01-preview",
|
||||
})
|
||||
|
||||
expect(buildEffectiveProviderConfig(parseProviderId("openai-compatible"))).toEqual({
|
||||
providerId: parseProviderId("openai-compatible"),
|
||||
apiKey: "provider-openai-key",
|
||||
baseUrl: "https://provider.example/v1",
|
||||
azure: { apiVersion: "2024-02-15-preview" },
|
||||
})
|
||||
})
|
||||
|
||||
it("carries Qwen apiLine from SDK provider settings", async () => {
|
||||
const { buildEffectiveProviderConfig } = await import("./effective-config")
|
||||
mocks.setProviderSettings({ qwen: { provider: "qwen", apiKey: "provider-qwen-key", apiLine: "china" } })
|
||||
mocks.setApiConfiguration({ qwenApiKey: "state-qwen-key", qwenApiLine: "international" })
|
||||
|
||||
expect(buildEffectiveProviderConfig(parseProviderId("qwen"))).toEqual({
|
||||
providerId: parseProviderId("qwen"),
|
||||
apiKey: "state-qwen-key",
|
||||
apiLine: "international",
|
||||
apiKey: "provider-qwen-key",
|
||||
apiLine: "china",
|
||||
})
|
||||
})
|
||||
|
||||
it("reads the Z.AI Coding Plan API key from provider-specific settings", async () => {
|
||||
const { buildEffectiveProviderConfig } = await import("./effective-config")
|
||||
mocks.setProviderSettings({
|
||||
"zai-coding-plan": { provider: "zai-coding-plan", apiKey: "provider-zai-coding-plan-key" },
|
||||
})
|
||||
mocks.setApiConfiguration({ zaiApiKey: "state-zai-key" })
|
||||
|
||||
expect(buildEffectiveProviderConfig(parseProviderId("zai-coding-plan"))).toEqual({
|
||||
providerId: parseProviderId("zai-coding-plan"),
|
||||
apiKey: "provider-zai-coding-plan-key",
|
||||
})
|
||||
})
|
||||
|
||||
it("does not reuse the legacy Z.AI API key for Z.AI Coding Plan", async () => {
|
||||
const { buildEffectiveProviderConfig } = await import("./effective-config")
|
||||
mocks.setProviderSettings({
|
||||
"zai-coding-plan": { provider: "zai-coding-plan" },
|
||||
})
|
||||
mocks.setApiConfiguration({ zaiApiKey: "state-zai-key" })
|
||||
|
||||
expect(buildEffectiveProviderConfig(parseProviderId("zai-coding-plan"))).toEqual({
|
||||
providerId: parseProviderId("zai-coding-plan"),
|
||||
})
|
||||
})
|
||||
|
||||
it("respects remote-config-locked LiteLLM key already applied by StateManager", async () => {
|
||||
it("respects remote-config-locked LiteLLM key over SDK provider settings", async () => {
|
||||
const { buildEffectiveProviderConfig } = await import("./effective-config")
|
||||
mocks.setProviderSettings({
|
||||
litellm: {
|
||||
@@ -151,7 +158,7 @@ describe("buildEffectiveProviderConfig", () => {
|
||||
baseUrl: "https://provider-litellm.example.com/v1",
|
||||
},
|
||||
})
|
||||
mocks.setApiConfiguration({
|
||||
mocks.setRemoteConfigSettings({
|
||||
liteLlmApiKey: "remote-config-locked-litellm-key",
|
||||
liteLlmBaseUrl: "https://remote-litellm.example.com/v1",
|
||||
})
|
||||
@@ -163,6 +170,24 @@ describe("buildEffectiveProviderConfig", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("falls back to legacy StateManager provider fields when SDK provider settings do not exist", async () => {
|
||||
const { buildEffectiveProviderConfig } = await import("./effective-config")
|
||||
mocks.setApiConfiguration({
|
||||
openAiApiKey: "state-openai-key",
|
||||
openAiBaseUrl: "https://state.example/v1",
|
||||
openAiHeaders: { "x-provider": "state" },
|
||||
azureApiVersion: "2025-01-01-preview",
|
||||
})
|
||||
|
||||
expect(buildEffectiveProviderConfig(parseProviderId("openai-compatible"))).toEqual({
|
||||
providerId: parseProviderId("openai-compatible"),
|
||||
apiKey: "state-openai-key",
|
||||
baseUrl: "https://state.example/v1",
|
||||
headers: { "x-provider": "state" },
|
||||
azure: { apiVersion: "2025-01-01-preview" },
|
||||
})
|
||||
})
|
||||
|
||||
it("keeps Cline account auth in the auth envelope", async () => {
|
||||
const { buildEffectiveProviderConfig } = await import("./effective-config")
|
||||
mocks.setApiConfiguration({ clineApiKey: "cline-access-token", clineAccountId: "account-123" })
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import type { ApiConfiguration } from "@shared/api"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { toLegacyApiProvider } from "@/shared/model-catalog/provider-helpers"
|
||||
import { getProviderSettingsManager } from "../provider-migration"
|
||||
import type { AwsProviderConfig, EffectiveProviderConfig, GcpProviderConfig, ProviderId } from "./contracts"
|
||||
import type {
|
||||
AwsProviderConfig,
|
||||
AzureProviderConfig,
|
||||
EffectiveProviderConfig,
|
||||
GcpProviderConfig,
|
||||
OcaProviderConfig,
|
||||
ProviderId,
|
||||
SapProviderConfig,
|
||||
} from "./contracts"
|
||||
import { toSdkProviderId } from "./sdk-provider-id"
|
||||
|
||||
type AuthConfig = NonNullable<EffectiveProviderConfig["auth"]>
|
||||
type ExtrasConfig = NonNullable<EffectiveProviderConfig["extras"]>
|
||||
@@ -17,16 +27,23 @@ type ProviderSettingsLike = {
|
||||
readonly region?: string
|
||||
readonly aws?: AwsProviderConfig
|
||||
readonly gcp?: GcpProviderConfig
|
||||
readonly azure?: AzureProviderConfig
|
||||
readonly sap?: SapProviderConfig
|
||||
readonly oca?: OcaProviderConfig
|
||||
readonly auth?: AuthConfig
|
||||
readonly extras?: ExtrasConfig
|
||||
}
|
||||
|
||||
type ProviderSettingsRead = {
|
||||
readonly exists: boolean
|
||||
readonly config: ConfigParts
|
||||
}
|
||||
|
||||
const apiKeyFields: Partial<Record<string, keyof ApiConfiguration>> = {
|
||||
anthropic: "apiKey",
|
||||
openrouter: "openRouterApiKey",
|
||||
openai: "openAiApiKey",
|
||||
"openai-native": "openAiNativeApiKey",
|
||||
"openai-codex": "openAiNativeApiKey",
|
||||
bedrock: "awsBedrockApiKey",
|
||||
gemini: "geminiApiKey",
|
||||
deepseek: "deepSeekApiKey",
|
||||
@@ -55,6 +72,7 @@ const apiKeyFields: Partial<Record<string, keyof ApiConfiguration>> = {
|
||||
hicap: "hicapApiKey",
|
||||
aihubmix: "aihubmixApiKey",
|
||||
nousresearch: "nousResearchApiKey",
|
||||
nousResearch: "nousResearchApiKey",
|
||||
"vercel-ai-gateway": "vercelAiGatewayApiKey",
|
||||
wandb: "wandbApiKey",
|
||||
oca: "ocaApiKey",
|
||||
@@ -174,6 +192,50 @@ function readGcp(record: Record<string, unknown>): GcpProviderConfig | undefined
|
||||
return Object.values(result).some((value) => value !== undefined) ? result : undefined
|
||||
}
|
||||
|
||||
function readAzure(record: Record<string, unknown>): AzureProviderConfig | undefined {
|
||||
const azure = record.azure
|
||||
if (!isPlainRecord(azure)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const result: AzureProviderConfig = {
|
||||
apiVersion: readString(azure, "apiVersion"),
|
||||
}
|
||||
return Object.values(result).some((value) => value !== undefined) ? result : undefined
|
||||
}
|
||||
|
||||
function readSap(record: Record<string, unknown>): SapProviderConfig | undefined {
|
||||
const sap = record.sap
|
||||
if (!isPlainRecord(sap)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const result: SapProviderConfig = {
|
||||
clientId: readString(sap, "clientId"),
|
||||
clientSecret: readString(sap, "clientSecret"),
|
||||
tokenUrl: readString(sap, "tokenUrl"),
|
||||
resourceGroup: readString(sap, "resourceGroup"),
|
||||
deploymentId: readString(sap, "deploymentId"),
|
||||
useOrchestrationMode: readBoolean(sap, "useOrchestrationMode"),
|
||||
api: readString(sap, "api"),
|
||||
defaultSettings: isPlainRecord(sap.defaultSettings) ? sap.defaultSettings : undefined,
|
||||
}
|
||||
return Object.values(result).some((value) => value !== undefined) ? result : undefined
|
||||
}
|
||||
|
||||
function readOca(record: Record<string, unknown>): OcaProviderConfig | undefined {
|
||||
const oca = record.oca
|
||||
if (!isPlainRecord(oca)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const result: OcaProviderConfig = {
|
||||
mode: readString(oca, "mode"),
|
||||
usePromptCache: readBoolean(oca, "usePromptCache"),
|
||||
}
|
||||
return Object.values(result).some((value) => value !== undefined) ? result : undefined
|
||||
}
|
||||
|
||||
function readAws(record: Record<string, unknown>): AwsProviderConfig | undefined {
|
||||
const aws = record.aws
|
||||
if (!isPlainRecord(aws)) {
|
||||
@@ -184,6 +246,7 @@ function readAws(record: Record<string, unknown>): AwsProviderConfig | undefined
|
||||
accessKey: readString(aws, "accessKey"),
|
||||
secretKey: readString(aws, "secretKey"),
|
||||
sessionToken: readString(aws, "sessionToken"),
|
||||
region: readString(aws, "region"),
|
||||
authentication: readString(aws, "authentication"),
|
||||
profile: readString(aws, "profile"),
|
||||
usePromptCache: readBoolean(aws, "usePromptCache"),
|
||||
@@ -195,26 +258,37 @@ function readAws(record: Record<string, unknown>): AwsProviderConfig | undefined
|
||||
return Object.values(result).some((value) => value !== undefined) ? result : undefined
|
||||
}
|
||||
|
||||
function readProviderSettings(providerId: ProviderId): ConfigParts {
|
||||
function readProviderSettings(providerId: ProviderId): ProviderSettingsRead {
|
||||
try {
|
||||
const settings: unknown = getProviderSettingsManager().getProviderSettings(providerId)
|
||||
const manager = getProviderSettingsManager()
|
||||
const settings: unknown =
|
||||
manager.getProviderSettings(toSdkProviderId(providerId)) ?? manager.getProviderSettings(providerId)
|
||||
if (!isPlainRecord(settings)) {
|
||||
return {}
|
||||
return { exists: false, config: {} }
|
||||
}
|
||||
const aws = readAws(settings)
|
||||
const gcp = readGcp(settings)
|
||||
const azure = readAzure(settings)
|
||||
|
||||
return {
|
||||
apiKey: readString(settings, "apiKey"),
|
||||
baseUrl: readString(settings, "baseUrl"),
|
||||
apiLine: readString(settings, "apiLine"),
|
||||
headers: readHeaders(settings, "headers"),
|
||||
region: readString(settings, "region"),
|
||||
aws: readAws(settings),
|
||||
gcp: readGcp(settings),
|
||||
auth: readAuth(settings),
|
||||
extras: isPlainRecord(settings.extras) ? settings.extras : undefined,
|
||||
} satisfies ProviderSettingsLike
|
||||
exists: true,
|
||||
config: {
|
||||
apiKey: readString(settings, "apiKey"),
|
||||
baseUrl: readString(settings, "baseUrl"),
|
||||
apiLine: readString(settings, "apiLine"),
|
||||
headers: readHeaders(settings, "headers"),
|
||||
region: readString(settings, "region") ?? aws?.region ?? gcp?.region,
|
||||
aws,
|
||||
gcp,
|
||||
azure,
|
||||
sap: readSap(settings),
|
||||
oca: readOca(settings),
|
||||
auth: readAuth(settings),
|
||||
extras: isPlainRecord(settings.extras) ? settings.extras : undefined,
|
||||
} satisfies ProviderSettingsLike,
|
||||
}
|
||||
} catch {
|
||||
return {}
|
||||
return { exists: false, config: {} }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,6 +357,17 @@ function readStateGcp(provider: string, config: ApiConfiguration): GcpProviderCo
|
||||
return Object.values(gcp).some((value) => value !== undefined) ? gcp : undefined
|
||||
}
|
||||
|
||||
function readStateAzure(provider: string, config: ApiConfiguration): AzureProviderConfig | undefined {
|
||||
if (provider !== "openai") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const azure: AzureProviderConfig = {
|
||||
apiVersion: readStringFromConfig(config, "azureApiVersion"),
|
||||
}
|
||||
return Object.values(azure).some((value) => value !== undefined) ? azure : undefined
|
||||
}
|
||||
|
||||
function readStateAws(provider: string, config: ApiConfiguration): AwsProviderConfig | undefined {
|
||||
if (provider !== "bedrock") {
|
||||
return undefined
|
||||
@@ -292,6 +377,7 @@ function readStateAws(provider: string, config: ApiConfiguration): AwsProviderCo
|
||||
accessKey: readStringFromConfig(config, "awsAccessKey"),
|
||||
secretKey: readStringFromConfig(config, "awsSecretKey"),
|
||||
sessionToken: readStringFromConfig(config, "awsSessionToken"),
|
||||
region: readStringFromConfig(config, "awsRegion"),
|
||||
authentication: readStringFromConfig(config, "awsAuthentication"),
|
||||
profile: readStringFromConfig(config, "awsProfile"),
|
||||
usePromptCache: readStateBoolean(config, "awsBedrockUsePromptCache"),
|
||||
@@ -302,8 +388,35 @@ function readStateAws(provider: string, config: ApiConfiguration): AwsProviderCo
|
||||
return Object.values(aws).some((value) => value !== undefined) ? aws : undefined
|
||||
}
|
||||
|
||||
function readStateSap(provider: string, config: ApiConfiguration): SapProviderConfig | undefined {
|
||||
if (provider !== "sapaicore") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const sap: SapProviderConfig = {
|
||||
clientId: readStringFromConfig(config, "sapAiCoreClientId"),
|
||||
clientSecret: readStringFromConfig(config, "sapAiCoreClientSecret"),
|
||||
tokenUrl: readStringFromConfig(config, "sapAiCoreTokenUrl"),
|
||||
resourceGroup: readStringFromConfig(config, "sapAiResourceGroup"),
|
||||
useOrchestrationMode: readStateBoolean(config, "sapAiCoreUseOrchestrationMode"),
|
||||
}
|
||||
return Object.values(sap).some((value) => value !== undefined) ? sap : undefined
|
||||
}
|
||||
|
||||
function readStateOca(provider: string, config: ApiConfiguration): OcaProviderConfig | undefined {
|
||||
if (provider !== "oca") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const mode = readStringFromConfig(config, "ocaMode")
|
||||
const oca: OcaProviderConfig = {
|
||||
mode,
|
||||
}
|
||||
return Object.values(oca).some((value) => value !== undefined) ? oca : undefined
|
||||
}
|
||||
|
||||
function readStateConfig(providerId: ProviderId, config: ApiConfiguration): ConfigParts {
|
||||
const provider = providerId.toString()
|
||||
const provider = toLegacyApiProvider(providerId.toString())
|
||||
return {
|
||||
apiKey: readStringFromConfig(config, apiKeyFields[provider]),
|
||||
baseUrl: readStringFromConfig(config, baseUrlFields[provider]),
|
||||
@@ -312,6 +425,9 @@ function readStateConfig(providerId: ProviderId, config: ApiConfiguration): Conf
|
||||
region: readStringFromConfig(config, regionFields[provider]),
|
||||
aws: readStateAws(provider, config),
|
||||
gcp: readStateGcp(provider, config),
|
||||
azure: readStateAzure(provider, config),
|
||||
sap: readStateSap(provider, config),
|
||||
oca: readStateOca(provider, config),
|
||||
auth: readStateAuth(provider, config),
|
||||
extras: readStateExtras(provider, config),
|
||||
}
|
||||
@@ -337,14 +453,39 @@ function mergeGcp(first: GcpProviderConfig | undefined, second: GcpProviderConfi
|
||||
return { ...first, ...second }
|
||||
}
|
||||
|
||||
function mergeAws(first: AwsProviderConfig | undefined, second: AwsProviderConfig | undefined): AwsProviderConfig | undefined {
|
||||
function mergeAzure(
|
||||
first: AzureProviderConfig | undefined,
|
||||
second: AzureProviderConfig | undefined,
|
||||
): AzureProviderConfig | undefined {
|
||||
return mergeDefined(first, second)
|
||||
}
|
||||
|
||||
function mergeDefined<T extends object>(first: T | undefined, second: T | undefined): T | undefined {
|
||||
if (!first) {
|
||||
return second
|
||||
}
|
||||
if (!second) {
|
||||
return first
|
||||
}
|
||||
return { ...first, ...second }
|
||||
const merged: Record<string, unknown> = { ...(first as Record<string, unknown>) }
|
||||
for (const [key, value] of Object.entries(second)) {
|
||||
if (value !== undefined) {
|
||||
merged[key] = value
|
||||
}
|
||||
}
|
||||
return merged as T
|
||||
}
|
||||
|
||||
function mergeSap(first: SapProviderConfig | undefined, second: SapProviderConfig | undefined): SapProviderConfig | undefined {
|
||||
return mergeDefined(first, second)
|
||||
}
|
||||
|
||||
function mergeOca(first: OcaProviderConfig | undefined, second: OcaProviderConfig | undefined): OcaProviderConfig | undefined {
|
||||
return mergeDefined(first, second)
|
||||
}
|
||||
|
||||
function mergeAws(first: AwsProviderConfig | undefined, second: AwsProviderConfig | undefined): AwsProviderConfig | undefined {
|
||||
return mergeDefined(first, second)
|
||||
}
|
||||
|
||||
function assignIfDefined<T extends ConfigKey>(target: Partial<ConfigParts>, key: T, value: ConfigParts[T] | undefined): void {
|
||||
@@ -354,30 +495,45 @@ function assignIfDefined<T extends ConfigKey>(target: Partial<ConfigParts>, key:
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an {@link EffectiveProviderConfig} by merging provider-owned settings
|
||||
* from SDK `providers.json` with the current StateManager effective API
|
||||
* configuration. StateManager's `getApiConfiguration()` already applies
|
||||
* task/session/remote-config overlays for legacy fields, so those values win.
|
||||
* Build an {@link EffectiveProviderConfig} from SDK `providers.json`.
|
||||
* Legacy StateManager config is a migration fallback for users that do not
|
||||
* have an SDK provider settings record yet; it is not a live shadow source
|
||||
* after SDK settings exist.
|
||||
*
|
||||
* Mode-dependent model selection is intentionally excluded; callers use
|
||||
* `ProviderConfigStore.readSelection(providerId, mode)` for that.
|
||||
*/
|
||||
export function buildEffectiveProviderConfig(providerId: ProviderId): EffectiveProviderConfig {
|
||||
const providerSettings = readProviderSettings(providerId)
|
||||
const providerSettingsRead = readProviderSettings(providerId)
|
||||
const providerSettings = providerSettingsRead.config
|
||||
const stateConfig = readStateConfig(providerId, StateManager.get().getApiConfiguration())
|
||||
const fallbackConfig: ConfigParts = providerSettingsRead.exists ? {} : stateConfig
|
||||
const merged: Partial<ConfigParts> = {}
|
||||
|
||||
assignIfDefined(merged, "apiKey", stateConfig.apiKey ?? providerSettings.apiKey)
|
||||
assignIfDefined(merged, "baseUrl", stateConfig.baseUrl ?? providerSettings.baseUrl)
|
||||
assignIfDefined(merged, "apiLine", stateConfig.apiLine ?? providerSettings.apiLine)
|
||||
assignIfDefined(merged, "headers", stateConfig.headers ?? providerSettings.headers)
|
||||
assignIfDefined(merged, "region", stateConfig.region ?? providerSettings.region)
|
||||
// Bedrock/Vertex are migrated to providers.json. Keep legacy StateManager cloud
|
||||
// fields as a fallback for old installs, but let providers.json win when both exist.
|
||||
assignIfDefined(merged, "aws", mergeAws(stateConfig.aws, providerSettings.aws))
|
||||
assignIfDefined(merged, "gcp", mergeGcp(stateConfig.gcp, providerSettings.gcp))
|
||||
assignIfDefined(merged, "auth", stateConfig.auth ?? providerSettings.auth)
|
||||
assignIfDefined(merged, "extras", mergeExtras(providerSettings.extras, stateConfig.extras))
|
||||
const remoteConfig = buildRemoteProviderConfig(providerId)
|
||||
|
||||
assignIfDefined(merged, "apiKey", remoteConfig.apiKey ?? providerSettings.apiKey ?? fallbackConfig.apiKey)
|
||||
assignIfDefined(merged, "baseUrl", remoteConfig.baseUrl ?? providerSettings.baseUrl ?? fallbackConfig.baseUrl)
|
||||
assignIfDefined(merged, "apiLine", remoteConfig.apiLine ?? providerSettings.apiLine ?? fallbackConfig.apiLine)
|
||||
assignIfDefined(merged, "headers", remoteConfig.headers ?? providerSettings.headers ?? fallbackConfig.headers)
|
||||
assignIfDefined(merged, "region", remoteConfig.region ?? providerSettings.region ?? fallbackConfig.region)
|
||||
assignIfDefined(merged, "aws", mergeAws(mergeAws(fallbackConfig.aws, providerSettings.aws), remoteConfig.aws))
|
||||
assignIfDefined(merged, "gcp", mergeGcp(mergeGcp(fallbackConfig.gcp, providerSettings.gcp), remoteConfig.gcp))
|
||||
assignIfDefined(merged, "azure", mergeAzure(mergeAzure(fallbackConfig.azure, providerSettings.azure), remoteConfig.azure))
|
||||
assignIfDefined(merged, "sap", mergeSap(mergeSap(fallbackConfig.sap, providerSettings.sap), remoteConfig.sap))
|
||||
assignIfDefined(merged, "oca", mergeOca(mergeOca(fallbackConfig.oca, providerSettings.oca), remoteConfig.oca))
|
||||
assignIfDefined(merged, "auth", providerSettings.auth ?? fallbackConfig.auth)
|
||||
assignIfDefined(merged, "extras", mergeExtras(fallbackConfig.extras, providerSettings.extras))
|
||||
|
||||
return { providerId, ...merged }
|
||||
}
|
||||
|
||||
export function buildRemoteProviderConfig(providerId: ProviderId): ConfigParts {
|
||||
try {
|
||||
const manager = StateManager.get() as { getRemoteConfigSettings?: () => unknown }
|
||||
const remoteConfigSettings = manager.getRemoteConfigSettings?.()
|
||||
return isPlainRecord(remoteConfigSettings) ? readStateConfig(providerId, remoteConfigSettings as ApiConfiguration) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,12 @@ describe("computeConfigFingerprint", () => {
|
||||
)
|
||||
})
|
||||
|
||||
it("changes when Azure OpenAI-compatible settings change", () => {
|
||||
expect(computeConfigFingerprint(providerId, makeConfig({ azure: { apiVersion: "2025-01-01-preview" } }))).not.toBe(
|
||||
computeConfigFingerprint(providerId, makeConfig({ azure: { apiVersion: "2024-02-15-preview" } })),
|
||||
)
|
||||
})
|
||||
|
||||
it("does not include raw secret sentinel values in the returned fingerprint", () => {
|
||||
const fingerprint = computeConfigFingerprint(
|
||||
providerId,
|
||||
|
||||
@@ -106,6 +106,15 @@ function sanitizeGcp(config: EffectiveProviderConfig["gcp"]): Readonly<Record<st
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeAzure(config: EffectiveProviderConfig["azure"]): Readonly<Record<string, unknown>> | null {
|
||||
if (!config) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
apiVersion: config.apiVersion ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeAws(config: EffectiveProviderConfig["aws"]): Readonly<Record<string, unknown>> | null {
|
||||
if (!config) {
|
||||
return null
|
||||
@@ -114,6 +123,7 @@ function sanitizeAws(config: EffectiveProviderConfig["aws"]): Readonly<Record<st
|
||||
accessKey: shortSecretHash(config.accessKey),
|
||||
secretKey: shortSecretHash(config.secretKey),
|
||||
sessionToken: shortSecretHash(config.sessionToken),
|
||||
region: config.region ?? null,
|
||||
authentication: config.authentication ?? null,
|
||||
profile: config.profile ?? null,
|
||||
usePromptCache: config.usePromptCache ?? null,
|
||||
@@ -124,6 +134,32 @@ function sanitizeAws(config: EffectiveProviderConfig["aws"]): Readonly<Record<st
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeSap(config: EffectiveProviderConfig["sap"]): Readonly<Record<string, unknown>> | null {
|
||||
if (!config) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
clientId: shortSecretHash(config.clientId),
|
||||
clientSecret: shortSecretHash(config.clientSecret),
|
||||
tokenUrl: config.tokenUrl ?? null,
|
||||
resourceGroup: config.resourceGroup ?? null,
|
||||
deploymentId: config.deploymentId ?? null,
|
||||
useOrchestrationMode: config.useOrchestrationMode ?? null,
|
||||
api: config.api ?? null,
|
||||
defaultSettings: sanitizeExtras(config.defaultSettings),
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeOca(config: EffectiveProviderConfig["oca"]): Readonly<Record<string, unknown>> | null {
|
||||
if (!config) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
mode: config.mode ?? null,
|
||||
usePromptCache: config.usePromptCache ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical JSON serialization with deterministic key ordering at every
|
||||
* object depth. Arrays preserve order because array order is meaningful.
|
||||
@@ -170,6 +206,9 @@ export function computeConfigFingerprint(providerId: ProviderId, config: Effecti
|
||||
region: config.region ?? null,
|
||||
aws: sanitizeAws(config.aws),
|
||||
gcp: sanitizeGcp(config.gcp),
|
||||
azure: sanitizeAzure(config.azure),
|
||||
sap: sanitizeSap(config.sap),
|
||||
oca: sanitizeOca(config.oca),
|
||||
extras: sanitizeExtras(config.extras),
|
||||
auth: {
|
||||
accountId: config.auth?.accountId ?? null,
|
||||
|
||||
@@ -45,10 +45,6 @@ describe("parseProviderId", () => {
|
||||
parseProviderId("anthropic")
|
||||
parseProviderId("openai")
|
||||
parseProviderId("nousResearch")
|
||||
parseProviderId("zai-coding-plan")
|
||||
parseProviderId("poolside")
|
||||
parseProviderId("v0")
|
||||
parseProviderId("xiaomi")
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -60,10 +56,6 @@ describe("isKnownProviderId", () => {
|
||||
expect(isKnownProviderId(parseProviderId("openai"))).toBe(true)
|
||||
expect(isKnownProviderId(parseProviderId("deepseek"))).toBe(true)
|
||||
expect(isKnownProviderId(parseProviderId("nousResearch"))).toBe(true)
|
||||
expect(isKnownProviderId(parseProviderId("zai-coding-plan"))).toBe(true)
|
||||
expect(isKnownProviderId(parseProviderId("poolside"))).toBe(true)
|
||||
expect(isKnownProviderId(parseProviderId("v0"))).toBe(true)
|
||||
expect(isKnownProviderId(parseProviderId("xiaomi"))).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false for a custom provider id", () => {
|
||||
|
||||
@@ -41,22 +41,18 @@ const KNOWN_API_PROVIDERS = {
|
||||
cerebras: true,
|
||||
sapaicore: true,
|
||||
groq: true,
|
||||
poolside: true,
|
||||
huggingface: true,
|
||||
"huawei-cloud-maas": true,
|
||||
dify: true,
|
||||
baseten: true,
|
||||
"vercel-ai-gateway": true,
|
||||
v0: true,
|
||||
zai: true,
|
||||
"zai-coding-plan": true,
|
||||
oca: true,
|
||||
aihubmix: true,
|
||||
minimax: true,
|
||||
hicap: true,
|
||||
nousResearch: true,
|
||||
wandb: true,
|
||||
xiaomi: true,
|
||||
"cline-pass": true,
|
||||
} satisfies Record<ApiProvider, true>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { openAiModelInfoSafeDefaults } from "@shared/api"
|
||||
import { ApiFormat } from "@shared/proto/cline/models"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { adaptSdkModelInfo, CatalogShapeError } from "./shape-adapter"
|
||||
|
||||
@@ -23,6 +24,9 @@ describe("adaptSdkModelInfo", () => {
|
||||
expect(() => adaptSdkModelInfo({ id: "m", maxTokens: "huge" })).toThrow(CatalogShapeError)
|
||||
expect(() => adaptSdkModelInfo({ id: "m", name: 1 })).toThrow(CatalogShapeError)
|
||||
expect(() => adaptSdkModelInfo({ id: "m", description: 1 })).toThrow(CatalogShapeError)
|
||||
expect(() => adaptSdkModelInfo({ id: "m", apiFormat: "messages" })).toThrow(CatalogShapeError)
|
||||
expect(() => adaptSdkModelInfo({ id: "m", temperature: "0" })).toThrow(CatalogShapeError)
|
||||
expect(() => adaptSdkModelInfo({ id: "m", thinkingConfig: "enabled" })).toThrow(CatalogShapeError)
|
||||
})
|
||||
|
||||
it("throws CatalogShapeError when capabilities is malformed", () => {
|
||||
@@ -37,6 +41,11 @@ describe("adaptSdkModelInfo", () => {
|
||||
expect(() => adaptSdkModelInfo({ id: "m", pricing: { input: Number.POSITIVE_INFINITY } })).toThrow(CatalogShapeError)
|
||||
})
|
||||
|
||||
it("throws CatalogShapeError when metadata is malformed", () => {
|
||||
expect(() => adaptSdkModelInfo({ id: "m", metadata: "sap" })).toThrow(CatalogShapeError)
|
||||
expect(() => adaptSdkModelInfo({ id: "m", metadata: [] })).toThrow(CatalogShapeError)
|
||||
})
|
||||
|
||||
it("CatalogShapeError exposes useful message and details", () => {
|
||||
try {
|
||||
adaptSdkModelInfo({ id: 5 })
|
||||
@@ -149,9 +158,13 @@ describe("adaptSdkModelInfo", () => {
|
||||
maxTokens: 8192,
|
||||
capabilities: ["tools", "reasoning", "structured_output", "temperature", "prompt-cache", "images"],
|
||||
pricing: { input: 0.5, output: 1.5, cacheRead: 0.05, cacheWrite: 0.1 },
|
||||
apiFormat: "openai-responses",
|
||||
temperature: 0,
|
||||
thinkingConfig: { maxBudget: 4096 },
|
||||
releaseDate: "2026-04-01",
|
||||
family: "deepseek",
|
||||
status: "ga",
|
||||
metadata: { sap: { deploymentId: "deployment-123" } },
|
||||
})
|
||||
|
||||
expect(model).toMatchObject({
|
||||
@@ -165,6 +178,12 @@ describe("adaptSdkModelInfo", () => {
|
||||
outputPrice: 1.5,
|
||||
cacheReadsPrice: 0.05,
|
||||
cacheWritesPrice: 0.1,
|
||||
apiFormat: ApiFormat.OPENAI_RESPONSES,
|
||||
temperature: 0,
|
||||
thinkingConfig: { maxBudget: 4096 },
|
||||
})
|
||||
expect((model as typeof model & { metadata?: Record<string, unknown> }).metadata).toEqual({
|
||||
sap: { deploymentId: "deployment-123" },
|
||||
})
|
||||
expect(Object.hasOwn(model, "releaseDate")).toBe(false)
|
||||
expect(Object.hasOwn(model, "family")).toBe(false)
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
* capabilities?: string[], // e.g. ["tools", "reasoning", "prompt-cache", "images"]
|
||||
* pricing?: { input?, output?, cacheRead?, cacheWrite? },
|
||||
* description?: string,
|
||||
* apiFormat?: "default" | "openai-responses" | "r1",
|
||||
* temperature?: number,
|
||||
* thinkingConfig?: object,
|
||||
* releaseDate?: string, // not mapped — see "Unmapped SDK fields" below
|
||||
* family?: string, // not mapped
|
||||
* status?: string, // not mapped
|
||||
@@ -37,19 +40,23 @@
|
||||
* | cacheReadsPrice | `sdk.pricing.cacheRead` if finite number | omitted (undefined) |
|
||||
* | cacheWritesPrice | `sdk.pricing.cacheWrite` if finite number | omitted (undefined) |
|
||||
* | description | `sdk.description` if string | omitted (undefined) |
|
||||
* | apiFormat | SDK `apiFormat` mapped to extension enum | omitted for `default`/missing |
|
||||
* | temperature | `sdk.temperature` if finite number | omitted (undefined) |
|
||||
* | thinkingConfig | `sdk.thinkingConfig` if object | omitted (undefined) |
|
||||
* | metadata | `sdk.metadata` if object | omitted (undefined) |
|
||||
*
|
||||
* Unmapped SDK fields intentionally dropped here: `releaseDate`, `family`,
|
||||
* `status`, and capabilities other than `images`/`vision`/`prompt-cache`/
|
||||
* `reasoning` (for example `tools`, `streaming`, `structured_output`,
|
||||
* `temperature`).
|
||||
*
|
||||
* Extension-only fields not populated by this adapter: `thinkingConfig`,
|
||||
* `tiers`, `temperature`, `apiFormat`, `supportsGlobalEndpoint`, and local
|
||||
* provider loaded-context overrides. Those require host enrichment or upstream
|
||||
* SDK metadata rather than adapter guesses.
|
||||
* Extension-only fields not populated by this adapter: `tiers`,
|
||||
* `supportsGlobalEndpoint`, and local provider loaded-context overrides.
|
||||
* Those require host enrichment or upstream SDK metadata rather than adapter guesses.
|
||||
*/
|
||||
|
||||
import { type ModelInfo, openAiModelInfoSafeDefaults } from "@shared/api"
|
||||
import { ApiFormat } from "@shared/proto/cline/models"
|
||||
|
||||
/**
|
||||
* Typed error thrown when SDK model-info shape validation fails. The catalog
|
||||
@@ -136,6 +143,21 @@ function readPricing(value: unknown): NormalizedPricing | undefined {
|
||||
return result
|
||||
}
|
||||
|
||||
function readApiFormat(value: unknown): ApiFormat | undefined {
|
||||
if (value === undefined || value === "default") {
|
||||
return undefined
|
||||
}
|
||||
if (value === "openai-responses") {
|
||||
return ApiFormat.OPENAI_RESPONSES
|
||||
}
|
||||
if (value === "r1") {
|
||||
return ApiFormat.R1_CHAT
|
||||
}
|
||||
throw new CatalogShapeError("SDK model-info `apiFormat` must be `default`, `openai-responses`, or `r1` when present.", {
|
||||
details: { receivedValue: value },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapt an SDK model-info shape into the extension's {@link ModelInfo} shape.
|
||||
*
|
||||
@@ -190,6 +212,19 @@ export function adaptSdkModelInfo(input: unknown): ModelInfo {
|
||||
|
||||
const capabilities = readStringArray(input.capabilities)
|
||||
const pricing = readPricing(input.pricing)
|
||||
const apiFormat = readApiFormat(input.apiFormat)
|
||||
const rawTemperature = input.temperature
|
||||
if (rawTemperature !== undefined && !isFiniteNumber(rawTemperature)) {
|
||||
throw new CatalogShapeError("SDK model-info `temperature` must be a finite number when present.", {
|
||||
details: { receivedType: typeof rawTemperature },
|
||||
})
|
||||
}
|
||||
const thinkingConfig = input.thinkingConfig
|
||||
if (thinkingConfig !== undefined && !isPlainObject(thinkingConfig)) {
|
||||
throw new CatalogShapeError("SDK model-info `thinkingConfig` must be an object when present.", {
|
||||
details: { receivedType: typeof thinkingConfig },
|
||||
})
|
||||
}
|
||||
|
||||
const result: ModelInfo = {
|
||||
name: rawName ?? id,
|
||||
@@ -220,6 +255,23 @@ export function adaptSdkModelInfo(input: unknown): ModelInfo {
|
||||
if (rawDescription !== undefined) {
|
||||
result.description = rawDescription
|
||||
}
|
||||
if (apiFormat !== undefined) {
|
||||
result.apiFormat = apiFormat
|
||||
}
|
||||
if (rawTemperature !== undefined) {
|
||||
result.temperature = rawTemperature
|
||||
}
|
||||
if (thinkingConfig !== undefined) {
|
||||
result.thinkingConfig = { ...thinkingConfig }
|
||||
}
|
||||
if (input.metadata !== undefined) {
|
||||
if (!isPlainObject(input.metadata)) {
|
||||
throw new CatalogShapeError("SDK model-info `metadata` must be an object when present.", {
|
||||
details: { receivedType: typeof input.metadata },
|
||||
})
|
||||
}
|
||||
;(result as ModelInfo & { metadata?: Record<string, unknown> }).metadata = { ...input.metadata }
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ProviderConfigChange } from "./contracts"
|
||||
import { parseProviderId } from "./provider-id"
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
type MockApiConfiguration = ApiConfiguration & { planActSeparateModelsSetting?: boolean }
|
||||
type MockApiConfiguration = ApiConfiguration
|
||||
let apiConfiguration: MockApiConfiguration = {}
|
||||
let providerSettingsById: Record<string, Record<string, unknown>> = {}
|
||||
const saveProviderSettings = vi.fn((settings: Record<string, unknown>, _options?: { setLastUsed?: boolean }) => {
|
||||
@@ -31,9 +31,6 @@ const mocks = vi.hoisted(() => {
|
||||
getSavedProviderSettings(providerId: string): Record<string, unknown> | undefined {
|
||||
return providerSettingsById[providerId]
|
||||
},
|
||||
getApiConfiguration(): MockApiConfiguration {
|
||||
return { ...apiConfiguration }
|
||||
},
|
||||
getSaveProviderSettingsMock(): typeof saveProviderSettings {
|
||||
return saveProviderSettings
|
||||
},
|
||||
@@ -117,6 +114,174 @@ describe("createProviderConfigStore", () => {
|
||||
expect(store.read(providerId).baseUrl).toBeUndefined()
|
||||
})
|
||||
|
||||
it("writes generic Bedrock SDK settings without mirroring legacy provider keys", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("bedrock")
|
||||
|
||||
store.write(providerId, {
|
||||
settings: {
|
||||
apiKey: "bedrock-api-key",
|
||||
aws: {
|
||||
authentication: "api-key",
|
||||
region: "us-west-2",
|
||||
accessKey: "access-key",
|
||||
secretKey: "secret-key",
|
||||
sessionToken: "session-token",
|
||||
endpoint: "https://bedrock.example",
|
||||
customModelBaseId: "base-profile",
|
||||
useCrossRegionInference: true,
|
||||
useGlobalInference: false,
|
||||
usePromptCache: true,
|
||||
},
|
||||
},
|
||||
apiKey: "bedrock-api-key",
|
||||
aws: {
|
||||
authentication: "api-key",
|
||||
region: "us-west-2",
|
||||
accessKey: "access-key",
|
||||
secretKey: "secret-key",
|
||||
sessionToken: "session-token",
|
||||
endpoint: "https://bedrock.example",
|
||||
customModelBaseId: "base-profile",
|
||||
useCrossRegionInference: true,
|
||||
useGlobalInference: false,
|
||||
usePromptCache: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(mocks.getSavedProviderSettings("bedrock")).toMatchObject({
|
||||
provider: "bedrock",
|
||||
apiKey: "bedrock-api-key",
|
||||
aws: {
|
||||
authentication: "api-key",
|
||||
region: "us-west-2",
|
||||
customModelBaseId: "base-profile",
|
||||
},
|
||||
})
|
||||
expect(mocks.getStateManager().getApiConfiguration()).toEqual({})
|
||||
})
|
||||
|
||||
it("stores formerly mode-scoped Bedrock and SAP SDK fields as provider settings", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
mocks.setApiConfiguration({
|
||||
planModeAwsBedrockCustomModelBaseId: "old-plan-base",
|
||||
actModeAwsBedrockCustomModelBaseId: "old-act-base",
|
||||
planModeSapAiCoreDeploymentId: "old-plan-deployment",
|
||||
actModeSapAiCoreDeploymentId: "old-act-deployment",
|
||||
})
|
||||
mocks.setProviderSettings({
|
||||
bedrock: { provider: "bedrock", aws: { customModelBaseId: "provider-wide-base" } },
|
||||
sapaicore: { provider: "sapaicore", sap: { deploymentId: "provider-wide-deployment" } },
|
||||
})
|
||||
const store = createProviderConfigStore()
|
||||
|
||||
store.write(parseProviderId("bedrock"), {
|
||||
mode: "plan",
|
||||
settings: { aws: { customModelBaseId: "new-plan-base" } },
|
||||
aws: { customModelBaseId: "new-plan-base" },
|
||||
})
|
||||
store.write(parseProviderId("sapaicore"), {
|
||||
mode: "act",
|
||||
settings: { sap: { deploymentId: "new-act-deployment" } },
|
||||
sap: { deploymentId: "new-act-deployment" },
|
||||
})
|
||||
|
||||
expect(mocks.getStateManager().getApiConfiguration()).toMatchObject({
|
||||
planModeAwsBedrockCustomModelBaseId: "old-plan-base",
|
||||
actModeAwsBedrockCustomModelBaseId: "old-act-base",
|
||||
planModeSapAiCoreDeploymentId: "old-plan-deployment",
|
||||
actModeSapAiCoreDeploymentId: "old-act-deployment",
|
||||
})
|
||||
expect(mocks.getSavedProviderSettings("bedrock")).toMatchObject({
|
||||
provider: "bedrock",
|
||||
aws: { customModelBaseId: "new-plan-base" },
|
||||
})
|
||||
expect(mocks.getSavedProviderSettings("sapaicore")).toMatchObject({
|
||||
provider: "sapaicore",
|
||||
sap: { deploymentId: "new-act-deployment" },
|
||||
})
|
||||
})
|
||||
|
||||
it("writes OpenAI-compatible SDK settings without mirroring legacy OpenAI keys", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
|
||||
store.write(parseProviderId("openai-compatible"), {
|
||||
settings: {
|
||||
apiKey: "openai-compatible-key",
|
||||
baseUrl: "https://compatible.example/v1",
|
||||
headers: { "x-provider": "compatible" },
|
||||
azure: { apiVersion: "2025-01-01-preview" },
|
||||
},
|
||||
apiKey: "openai-compatible-key",
|
||||
baseUrl: "https://compatible.example/v1",
|
||||
headers: { "x-provider": "compatible" },
|
||||
azure: { apiVersion: "2025-01-01-preview" },
|
||||
})
|
||||
|
||||
expect(mocks.getSavedProviderSettings("openai-compatible")).toMatchObject({
|
||||
provider: "openai-compatible",
|
||||
apiKey: "openai-compatible-key",
|
||||
baseUrl: "https://compatible.example/v1",
|
||||
headers: { "x-provider": "compatible" },
|
||||
azure: { apiVersion: "2025-01-01-preview" },
|
||||
})
|
||||
expect(mocks.getStateManager().getApiConfiguration()).toEqual({})
|
||||
})
|
||||
|
||||
it("writes generic SAP and OCA SDK settings without mirroring legacy provider keys", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
|
||||
store.write(parseProviderId("sapaicore"), {
|
||||
settings: {
|
||||
baseUrl: "https://sap.example",
|
||||
sap: {
|
||||
clientId: "sap-client",
|
||||
clientSecret: "sap-secret",
|
||||
tokenUrl: "https://auth.sap.example",
|
||||
resourceGroup: "sap-group",
|
||||
deploymentId: "sap-deployment",
|
||||
useOrchestrationMode: true,
|
||||
},
|
||||
},
|
||||
baseUrl: "https://sap.example",
|
||||
sap: {
|
||||
clientId: "sap-client",
|
||||
clientSecret: "sap-secret",
|
||||
tokenUrl: "https://auth.sap.example",
|
||||
resourceGroup: "sap-group",
|
||||
deploymentId: "sap-deployment",
|
||||
useOrchestrationMode: true,
|
||||
},
|
||||
})
|
||||
store.write(parseProviderId("oca"), {
|
||||
settings: { baseUrl: "https://oca.example", oca: { mode: "external", usePromptCache: true } },
|
||||
baseUrl: "https://oca.example",
|
||||
oca: { mode: "external", usePromptCache: true },
|
||||
})
|
||||
|
||||
expect(mocks.getSavedProviderSettings("sapaicore")).toMatchObject({
|
||||
provider: "sapaicore",
|
||||
baseUrl: "https://sap.example",
|
||||
sap: {
|
||||
clientId: "sap-client",
|
||||
clientSecret: "sap-secret",
|
||||
tokenUrl: "https://auth.sap.example",
|
||||
resourceGroup: "sap-group",
|
||||
deploymentId: "sap-deployment",
|
||||
useOrchestrationMode: true,
|
||||
},
|
||||
})
|
||||
expect(mocks.getSavedProviderSettings("oca")).toMatchObject({
|
||||
provider: "oca",
|
||||
baseUrl: "https://oca.example",
|
||||
oca: { mode: "external", usePromptCache: true },
|
||||
})
|
||||
expect(mocks.getStateManager().getApiConfiguration()).toEqual({})
|
||||
})
|
||||
|
||||
it("round-trips commitSelection then readSelection for provider-specific model info", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
@@ -181,29 +346,13 @@ describe("createProviderConfigStore", () => {
|
||||
|
||||
expect(written).toEqual({ providerId, apiKey: "nous-key" })
|
||||
expect(store.readSelection(providerId, "act")).toEqual(selection)
|
||||
expect(mocks.getSavedProviderSettings("nousresearch")).toMatchObject({
|
||||
provider: "nousresearch",
|
||||
expect(mocks.getSavedProviderSettings("nousResearch")).toMatchObject({
|
||||
provider: "nousResearch",
|
||||
apiKey: "nous-key",
|
||||
model: "nousresearch/hermes-4-70b",
|
||||
})
|
||||
})
|
||||
|
||||
it("writes Z.AI Coding Plan API keys only to provider-specific settings", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
mocks.setApiConfiguration({ zaiApiKey: "shared-zai-key" })
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("zai-coding-plan")
|
||||
|
||||
const written = store.write(providerId, { apiKey: "coding-plan-key" })
|
||||
|
||||
expect(written).toEqual({ providerId, apiKey: "coding-plan-key" })
|
||||
expect(mocks.getSavedProviderSettings("zai-coding-plan")).toMatchObject({
|
||||
provider: "zai-coding-plan",
|
||||
apiKey: "coding-plan-key",
|
||||
})
|
||||
expect(mocks.getApiConfiguration().zaiApiKey).toBe("shared-zai-key")
|
||||
})
|
||||
|
||||
it("returns undefined from readSelection when modelId or modelInfo is missing", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
@@ -216,9 +365,8 @@ describe("createProviderConfigStore", () => {
|
||||
expect(store.readSelection(providerId, "act")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("keeps Plan and Act selections independent and mirrors the latest selection to provider settings", async () => {
|
||||
it("uses the latest provider setting selection for both modes", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
mocks.setApiConfiguration({ planActSeparateModelsSetting: true })
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openrouter")
|
||||
const planSelection = { providerId, modelId: "provider/model-a", modelInfo: modelInfoA }
|
||||
@@ -227,22 +375,19 @@ describe("createProviderConfigStore", () => {
|
||||
store.commitSelection(providerId, "plan", planSelection)
|
||||
store.commitSelection(providerId, "act", actSelection)
|
||||
|
||||
expect(store.readSelection(providerId, "plan")).toEqual(planSelection)
|
||||
expect(store.readSelection(providerId, "plan")).toEqual(actSelection)
|
||||
expect(store.readSelection(providerId, "act")).toEqual(actSelection)
|
||||
expect(mocks.getSavedProviderSettings("openrouter")).toMatchObject({
|
||||
provider: "openrouter",
|
||||
model: "provider/model-b",
|
||||
contextWindow: 64_000,
|
||||
maxTokens: 4_096,
|
||||
})
|
||||
expect(mocks.getSavedProviderSettings("openrouter")).not.toHaveProperty("contextWindow")
|
||||
expect(mocks.getSavedProviderSettings("openrouter")).not.toHaveProperty("maxTokens")
|
||||
})
|
||||
|
||||
it("updates providers.json model with setLastUsed false when planActSeparateModelsSetting=false", async () => {
|
||||
it("updates providers.json model with setLastUsed false", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
mocks.setApiConfiguration({ planActSeparateModelsSetting: false })
|
||||
mocks.setProviderSettings({
|
||||
openrouter: { provider: "openrouter", apiKey: "existing-key", contextWindow: 64_000, maxTokens: 4_096 },
|
||||
})
|
||||
mocks.setProviderSettings({ openrouter: { provider: "openrouter", apiKey: "existing-key" } })
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("openrouter")
|
||||
const selection = { providerId, modelId: "provider/model-a", modelInfo: modelInfoA }
|
||||
@@ -254,8 +399,6 @@ describe("createProviderConfigStore", () => {
|
||||
apiKey: "existing-key",
|
||||
model: "provider/model-a",
|
||||
})
|
||||
expect(mocks.getSavedProviderSettings("openrouter")).not.toHaveProperty("contextWindow")
|
||||
expect(mocks.getSavedProviderSettings("openrouter")).not.toHaveProperty("maxTokens")
|
||||
expect(mocks.getSaveProviderSettingsMock()).toHaveBeenCalledWith(expect.objectContaining({ model: "provider/model-a" }), {
|
||||
setLastUsed: false,
|
||||
})
|
||||
@@ -272,9 +415,9 @@ describe("createProviderConfigStore", () => {
|
||||
expect(mocks.getSavedProviderSettings("claude-code")).toMatchObject({
|
||||
provider: "claude-code",
|
||||
model: "haiku",
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 8_192,
|
||||
})
|
||||
expect(mocks.getSavedProviderSettings("claude-code")).not.toHaveProperty("contextWindow")
|
||||
expect(mocks.getSavedProviderSettings("claude-code")).not.toHaveProperty("maxTokens")
|
||||
expect(mocks.getSaveProviderSettingsMock()).toHaveBeenCalledWith(expect.objectContaining({ model: "haiku" }), {
|
||||
setLastUsed: false,
|
||||
})
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { getGeneratedModelsForProvider, MODEL_COLLECTIONS_BY_PROVIDER_ID } from "@cline/llms"
|
||||
import { type ApiConfiguration, type ApiProvider, type ModelInfo, openAiModelInfoSafeDefaults } from "@shared/api"
|
||||
import { getProviderModelIdKey } from "@shared/storage/provider-keys"
|
||||
import { isSecretKey, isSettingsKey, type SecretKey, type SettingsKey } from "@shared/storage/state-keys"
|
||||
import type { SettingsKey } from "@shared/storage/state-keys"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { toLegacyApiProvider } from "@/shared/model-catalog/provider-helpers"
|
||||
import { getProviderSettingsManager } from "../provider-migration"
|
||||
import type {
|
||||
Disposable,
|
||||
@@ -21,76 +22,11 @@ import { toSdkProviderId } from "./sdk-provider-id"
|
||||
import { adaptSdkModelInfo } from "./shape-adapter"
|
||||
|
||||
type ProviderSettingsRecord = Record<string, unknown>
|
||||
type ProviderSettingsPatchKey = "apiKey" | "baseUrl" | "apiLine" | "headers" | "region" | "auth" | "extras" | "aws" | "gcp"
|
||||
|
||||
type ModelInfoKeys = {
|
||||
readonly plan: keyof ApiConfiguration & SettingsKey
|
||||
readonly act: keyof ApiConfiguration & SettingsKey
|
||||
}
|
||||
|
||||
const providerConfigStateKeys: Record<ProviderSettingsPatchKey, Partial<Record<string, SecretKey | SettingsKey>>> = {
|
||||
apiKey: {
|
||||
anthropic: "apiKey",
|
||||
openrouter: "openRouterApiKey",
|
||||
openai: "openAiApiKey",
|
||||
"openai-native": "openAiNativeApiKey",
|
||||
"openai-codex": "openAiNativeApiKey",
|
||||
bedrock: "awsBedrockApiKey",
|
||||
gemini: "geminiApiKey",
|
||||
deepseek: "deepSeekApiKey",
|
||||
ollama: "ollamaApiKey",
|
||||
requesty: "requestyApiKey",
|
||||
together: "togetherApiKey",
|
||||
fireworks: "fireworksApiKey",
|
||||
qwen: "qwenApiKey",
|
||||
"qwen-code": "qwenApiKey",
|
||||
doubao: "doubaoApiKey",
|
||||
mistral: "mistralApiKey",
|
||||
litellm: "liteLlmApiKey",
|
||||
asksage: "asksageApiKey",
|
||||
xai: "xaiApiKey",
|
||||
moonshot: "moonshotApiKey",
|
||||
zai: "zaiApiKey",
|
||||
huggingface: "huggingFaceApiKey",
|
||||
nebius: "nebiusApiKey",
|
||||
sambanova: "sambanovaApiKey",
|
||||
cerebras: "cerebrasApiKey",
|
||||
groq: "groqApiKey",
|
||||
baseten: "basetenApiKey",
|
||||
"huawei-cloud-maas": "huaweiCloudMaasApiKey",
|
||||
dify: "difyApiKey",
|
||||
minimax: "minimaxApiKey",
|
||||
hicap: "hicapApiKey",
|
||||
aihubmix: "aihubmixApiKey",
|
||||
nousresearch: "nousResearchApiKey",
|
||||
"vercel-ai-gateway": "vercelAiGatewayApiKey",
|
||||
wandb: "wandbApiKey",
|
||||
oca: "ocaApiKey",
|
||||
cline: "clineApiKey",
|
||||
},
|
||||
baseUrl: {
|
||||
anthropic: "anthropicBaseUrl",
|
||||
openai: "openAiBaseUrl",
|
||||
ollama: "ollamaBaseUrl",
|
||||
lmstudio: "lmStudioBaseUrl",
|
||||
gemini: "geminiBaseUrl",
|
||||
requesty: "requestyBaseUrl",
|
||||
asksage: "asksageApiUrl",
|
||||
litellm: "liteLlmBaseUrl",
|
||||
sapaicore: "sapAiCoreBaseUrl",
|
||||
dify: "difyBaseUrl",
|
||||
oca: "ocaBaseUrl",
|
||||
aihubmix: "aihubmixBaseUrl",
|
||||
},
|
||||
apiLine: { qwen: "qwenApiLine", moonshot: "moonshotApiLine", zai: "zaiApiLine", minimax: "minimaxApiLine" },
|
||||
headers: { openai: "openAiHeaders" },
|
||||
region: { bedrock: "awsRegion", vertex: "vertexRegion" },
|
||||
auth: {},
|
||||
extras: {},
|
||||
aws: {},
|
||||
gcp: {},
|
||||
}
|
||||
|
||||
const modelInfoKeysByProvider: Partial<Record<string, ModelInfoKeys>> = {
|
||||
openrouter: { plan: "planModeOpenRouterModelInfo", act: "actModeOpenRouterModelInfo" },
|
||||
cline: { plan: "planModeClineModelInfo", act: "actModeClineModelInfo" },
|
||||
@@ -107,28 +43,13 @@ const modelInfoKeysByProvider: Partial<Record<string, ModelInfoKeys>> = {
|
||||
"vercel-ai-gateway": { plan: "planModeVercelAiGatewayModelInfo", act: "actModeVercelAiGatewayModelInfo" },
|
||||
}
|
||||
|
||||
// In-memory selection envelope for providers that have a mode-specific model
|
||||
// id key but no durable `*ModelInfo` key in the StateManager schema (for
|
||||
// example DeepSeek/Gemini/generic SDK-backed providers). Keyed by
|
||||
// provider+mode so that switching between providers that share the same
|
||||
// `*ModeApiModelId` key does not combine one provider's model id with
|
||||
// another provider's model info.
|
||||
const selectionMemory = new Map<string, ModelSelection>()
|
||||
|
||||
function providerKey(providerId: ProviderId): string {
|
||||
return providerId.toString()
|
||||
}
|
||||
|
||||
function providerForStorage(providerId: ProviderId): ApiProvider | undefined {
|
||||
const key = providerKey(providerId)
|
||||
if (key === "nousresearch") {
|
||||
return "nousResearch"
|
||||
}
|
||||
return key as ApiProvider
|
||||
}
|
||||
|
||||
function memoryKey(providerId: ProviderId, mode: Mode): string {
|
||||
return `${providerId}:${mode}`
|
||||
return toLegacyApiProvider(key)
|
||||
}
|
||||
|
||||
function modePair<T>(mode: Mode, plan: T, act: T): T {
|
||||
@@ -152,18 +73,16 @@ function isModelInfo(value: unknown): value is ModelInfo {
|
||||
return isRecord(value) && typeof value.supportsPromptCache === "boolean"
|
||||
}
|
||||
|
||||
function isKnownModelIdForProvider(providerId: ProviderId, modelId: string): boolean {
|
||||
const sdkProviderId = toSdkProviderId(providerId)
|
||||
return Boolean(
|
||||
getGeneratedModelsForProvider(sdkProviderId)[modelId] || MODEL_COLLECTIONS_BY_PROVIDER_ID[sdkProviderId]?.models[modelId],
|
||||
)
|
||||
}
|
||||
|
||||
function readProviderSettingsModelId(providerId: ProviderId): string | undefined {
|
||||
const model = getProviderSettings(providerId).model
|
||||
return typeof model === "string" && model.trim().length > 0 ? model.trim() : undefined
|
||||
}
|
||||
|
||||
function readProviderSettingsModelInfo(providerId: ProviderId): ModelInfo | undefined {
|
||||
const modelInfo = getProviderSettings(providerId).modelInfo
|
||||
return isModelInfo(modelInfo) ? modelInfo : undefined
|
||||
}
|
||||
|
||||
function fallbackModelInfo(modelId: string): ModelInfo {
|
||||
return { ...openAiModelInfoSafeDefaults, name: modelId }
|
||||
}
|
||||
@@ -206,91 +125,53 @@ function readSelectionFromProviderSettings(providerId: ProviderId): ModelSelecti
|
||||
return {
|
||||
providerId,
|
||||
modelId,
|
||||
modelInfo: readKnownModelInfoForProvider(providerId, modelId) ?? fallbackModelInfo(modelId),
|
||||
}
|
||||
}
|
||||
|
||||
function writeStateKey(key: SecretKey | SettingsKey, value: unknown): void {
|
||||
const stateManager = StateManager.get()
|
||||
if (isSecretKey(key)) {
|
||||
stateManager.setSecret(key, typeof value === "string" ? value : undefined)
|
||||
return
|
||||
}
|
||||
if (isSettingsKey(key)) {
|
||||
stateManager.setGlobalState(key, value as never)
|
||||
}
|
||||
}
|
||||
|
||||
function writeStateFields(providerId: ProviderId, patch: ProviderConfigPatch): void {
|
||||
const provider = providerKey(providerId)
|
||||
for (const key of ["apiKey", "baseUrl", "apiLine", "headers", "region"] as const) {
|
||||
if (!(key in patch)) {
|
||||
continue
|
||||
}
|
||||
const stateKey = providerConfigStateKeys[key][provider]
|
||||
if (stateKey) {
|
||||
const value = typeof patch[key] === "string" ? patchStringValue(patch[key]) : patchValue(patch[key])
|
||||
writeStateKey(stateKey, value)
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === "vertex" && "gcp" in patch) {
|
||||
const gcp = patch.gcp
|
||||
if (gcp === null || gcp === undefined) {
|
||||
writeStateKey("vertexProjectId", undefined)
|
||||
writeStateKey("vertexRegion", undefined)
|
||||
} else {
|
||||
if ("projectId" in gcp) writeStateKey("vertexProjectId", patchStringValue(gcp.projectId))
|
||||
if ("region" in gcp) writeStateKey("vertexRegion", patchStringValue(gcp.region))
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === "bedrock" && "aws" in patch) {
|
||||
const aws = patch.aws
|
||||
if (aws === null || aws === undefined) {
|
||||
writeStateKey("awsAccessKey", undefined)
|
||||
writeStateKey("awsSecretKey", undefined)
|
||||
writeStateKey("awsSessionToken", undefined)
|
||||
writeStateKey("awsAuthentication", undefined)
|
||||
writeStateKey("awsProfile", undefined)
|
||||
writeStateKey("awsBedrockUsePromptCache", undefined)
|
||||
writeStateKey("awsBedrockEndpoint", undefined)
|
||||
} else {
|
||||
if ("accessKey" in aws) writeStateKey("awsAccessKey", patchStringValue(aws.accessKey))
|
||||
if ("secretKey" in aws) writeStateKey("awsSecretKey", patchStringValue(aws.secretKey))
|
||||
if ("sessionToken" in aws) writeStateKey("awsSessionToken", patchStringValue(aws.sessionToken))
|
||||
if ("authentication" in aws) writeStateKey("awsAuthentication", patchStringValue(aws.authentication))
|
||||
if ("profile" in aws) writeStateKey("awsProfile", patchStringValue(aws.profile))
|
||||
if ("usePromptCache" in aws) writeStateKey("awsBedrockUsePromptCache", aws.usePromptCache)
|
||||
if ("endpoint" in aws) writeStateKey("awsBedrockEndpoint", patchStringValue(aws.endpoint))
|
||||
if ("customModelBaseId" in aws) {
|
||||
const customModelBaseId = patchStringValue(aws.customModelBaseId)
|
||||
writeStateKey("planModeAwsBedrockCustomModelBaseId", customModelBaseId)
|
||||
writeStateKey("actModeAwsBedrockCustomModelBaseId", customModelBaseId)
|
||||
}
|
||||
if ("useCrossRegionInference" in aws) writeStateKey("awsUseCrossRegionInference", aws.useCrossRegionInference)
|
||||
if ("useGlobalInference" in aws) writeStateKey("awsUseGlobalInference", aws.useGlobalInference)
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === "cline" && "auth" in patch) {
|
||||
writeStateKey("clineApiKey", patch.auth?.accessToken)
|
||||
writeStateKey("clineAccountId", patch.auth?.accountId)
|
||||
modelInfo:
|
||||
readProviderSettingsModelInfo(providerId) ??
|
||||
readKnownModelInfoForProvider(providerId, modelId) ??
|
||||
fallbackModelInfo(modelId),
|
||||
}
|
||||
}
|
||||
|
||||
function getProviderSettings(providerId: ProviderId): ProviderSettingsRecord {
|
||||
const settings = getProviderSettingsManager().getProviderSettings(providerId)
|
||||
const manager = getProviderSettingsManager()
|
||||
const sdkProviderId = toSdkProviderId(providerId)
|
||||
const settings = manager.getProviderSettings(sdkProviderId) ?? manager.getProviderSettings(providerId)
|
||||
return isRecord(settings) ? settings : {}
|
||||
}
|
||||
|
||||
function saveProviderSettings(providerId: ProviderId, next: ProviderSettingsRecord): void {
|
||||
getProviderSettingsManager().saveProviderSettings({ provider: providerId, ...next }, { setLastUsed: false })
|
||||
const sdkProviderId = toSdkProviderId(providerId)
|
||||
getProviderSettingsManager().saveProviderSettings({ provider: sdkProviderId, ...next }, { setLastUsed: false })
|
||||
}
|
||||
|
||||
function mergeProviderSettingsRecord(
|
||||
base: ProviderSettingsRecord,
|
||||
patch: Readonly<Record<string, unknown>> | undefined,
|
||||
): ProviderSettingsRecord {
|
||||
if (!patch) {
|
||||
return base
|
||||
}
|
||||
const next: ProviderSettingsRecord = { ...base }
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === undefined) {
|
||||
continue
|
||||
}
|
||||
if (value === null) {
|
||||
delete next[key]
|
||||
continue
|
||||
}
|
||||
const existingValue = next[key]
|
||||
if (isRecord(existingValue) && isRecord(value)) {
|
||||
next[key] = mergeProviderSettingsRecord(existingValue, value)
|
||||
} else {
|
||||
next[key] = value
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function writeProviderSettingsFields(providerId: ProviderId, patch: ProviderConfigPatch): void {
|
||||
const existing = getProviderSettings(providerId)
|
||||
const next: ProviderSettingsRecord = { ...existing }
|
||||
const next: ProviderSettingsRecord = mergeProviderSettingsRecord(getProviderSettings(providerId), patch.settings)
|
||||
|
||||
for (const key of ["apiKey", "baseUrl", "apiLine", "headers", "region", "auth", "extras"] as const) {
|
||||
if (key in patch) {
|
||||
@@ -303,6 +184,50 @@ function writeProviderSettingsFields(providerId: ProviderId, patch: ProviderConf
|
||||
}
|
||||
}
|
||||
|
||||
if ("sap" in patch) {
|
||||
const sapPatch = patch.sap
|
||||
if (sapPatch === null || sapPatch === undefined) {
|
||||
delete next.sap
|
||||
} else {
|
||||
const existingSap = isRecord(next.sap) ? next.sap : {}
|
||||
const nextSap: ProviderSettingsRecord = { ...existingSap }
|
||||
for (const [key, value] of Object.entries(sapPatch)) {
|
||||
if (typeof value === "string" && value.length === 0) {
|
||||
delete nextSap[key]
|
||||
} else {
|
||||
nextSap[key] = value
|
||||
}
|
||||
}
|
||||
if (Object.keys(nextSap).length === 0) {
|
||||
delete next.sap
|
||||
} else {
|
||||
next.sap = nextSap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ("oca" in patch) {
|
||||
const ocaPatch = patch.oca
|
||||
if (ocaPatch === null || ocaPatch === undefined) {
|
||||
delete next.oca
|
||||
} else {
|
||||
const existingOca = isRecord(next.oca) ? next.oca : {}
|
||||
const nextOca: ProviderSettingsRecord = { ...existingOca }
|
||||
for (const [key, value] of Object.entries(ocaPatch)) {
|
||||
if (typeof value === "string" && value.length === 0) {
|
||||
delete nextOca[key]
|
||||
} else {
|
||||
nextOca[key] = value
|
||||
}
|
||||
}
|
||||
if (Object.keys(nextOca).length === 0) {
|
||||
delete next.oca
|
||||
} else {
|
||||
next.oca = nextOca
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ("gcp" in patch) {
|
||||
const gcpPatch = patch.gcp
|
||||
if (gcpPatch === null || gcpPatch === undefined) {
|
||||
@@ -325,6 +250,28 @@ function writeProviderSettingsFields(providerId: ProviderId, patch: ProviderConf
|
||||
}
|
||||
}
|
||||
|
||||
if ("azure" in patch) {
|
||||
const azurePatch = patch.azure
|
||||
if (azurePatch === null || azurePatch === undefined) {
|
||||
delete next.azure
|
||||
} else {
|
||||
const existingAzure = isRecord(next.azure) ? next.azure : {}
|
||||
const nextAzure: ProviderSettingsRecord = { ...existingAzure }
|
||||
for (const [key, value] of Object.entries(azurePatch)) {
|
||||
if (typeof value === "string" && value.length === 0) {
|
||||
delete nextAzure[key]
|
||||
} else {
|
||||
nextAzure[key] = value
|
||||
}
|
||||
}
|
||||
if (Object.keys(nextAzure).length === 0) {
|
||||
delete next.azure
|
||||
} else {
|
||||
next.azure = nextAzure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ("aws" in patch) {
|
||||
const awsPatch = patch.aws
|
||||
if (awsPatch === null || awsPatch === undefined) {
|
||||
@@ -339,7 +286,11 @@ function writeProviderSettingsFields(providerId: ProviderId, patch: ProviderConf
|
||||
nextAws[key] = value
|
||||
}
|
||||
}
|
||||
next.aws = nextAws
|
||||
if (Object.keys(nextAws).length === 0) {
|
||||
delete next.aws
|
||||
} else {
|
||||
next.aws = nextAws
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,47 +327,42 @@ function getModelIdKey(providerId: ProviderId, mode: Mode): keyof ApiConfigurati
|
||||
}
|
||||
|
||||
function getModelInfoKey(providerId: ProviderId, mode: Mode): (keyof ApiConfiguration & SettingsKey) | undefined {
|
||||
const keys = modelInfoKeysByProvider[providerKey(providerId)]
|
||||
const keys = modelInfoKeysByProvider[toLegacyApiProvider(providerKey(providerId))]
|
||||
return keys ? modePair(mode, keys.plan, keys.act) : undefined
|
||||
}
|
||||
|
||||
function syncedModes(mode: Mode): Mode[] {
|
||||
return StateManager.get().getGlobalSettingsKey("planActSeparateModelsSetting") ? [mode] : ["plan", "act"]
|
||||
}
|
||||
|
||||
function writeSelectionToState(providerId: ProviderId, mode: Mode, selection: ModelSelection): void {
|
||||
const updates: Partial<Record<SettingsKey, unknown>> = {}
|
||||
for (const targetMode of syncedModes(mode)) {
|
||||
updates[getModelIdKey(providerId, targetMode)] = selection.modelId
|
||||
const modelInfoKey = getModelInfoKey(providerId, targetMode)
|
||||
if (modelInfoKey) {
|
||||
updates[modelInfoKey] = selection.modelInfo
|
||||
}
|
||||
selectionMemory.set(memoryKey(providerId, targetMode), { ...selection, providerId })
|
||||
}
|
||||
StateManager.get().setGlobalStateBatch(updates as never)
|
||||
}
|
||||
|
||||
function writeSelectionToProviderSettings(providerId: ProviderId, selection: ModelSelection): void {
|
||||
const next: ProviderSettingsRecord = { ...getProviderSettings(providerId), model: selection.modelId }
|
||||
// Prune model metadata that earlier builds may have written to providers.json.
|
||||
delete next.contextWindow
|
||||
delete next.maxTokens
|
||||
const next: ProviderSettingsRecord = {
|
||||
...getProviderSettings(providerId),
|
||||
model: selection.modelId,
|
||||
modelInfo: selection.modelInfo,
|
||||
}
|
||||
|
||||
if (selection.modelInfo.contextWindow !== undefined && selection.modelInfo.contextWindow > 0) {
|
||||
next.contextWindow = selection.modelInfo.contextWindow
|
||||
}
|
||||
|
||||
if (selection.modelInfo.maxTokens !== undefined && selection.modelInfo.maxTokens > 0) {
|
||||
next.maxTokens = selection.modelInfo.maxTokens
|
||||
}
|
||||
|
||||
saveProviderSettings(providerId, next)
|
||||
}
|
||||
|
||||
function readSelectionFromState(providerId: ProviderId, mode: Mode): ModelSelection | undefined {
|
||||
const providerSettingsSelection = readSelectionFromProviderSettings(providerId)
|
||||
if (providerSettingsSelection) {
|
||||
return providerSettingsSelection
|
||||
}
|
||||
|
||||
const apiConfiguration = StateManager.get().getApiConfiguration()
|
||||
const modelId = apiConfiguration[getModelIdKey(providerId, mode)]
|
||||
const modelInfoKey = getModelInfoKey(providerId, mode)
|
||||
const rememberedSelection = selectionMemory.get(memoryKey(providerId, mode))
|
||||
const providerSettingsSelection = readSelectionFromProviderSettings(providerId)
|
||||
|
||||
if (modelInfoKey) {
|
||||
const modelInfo = apiConfiguration[modelInfoKey]
|
||||
if (typeof modelId !== "string" || modelId.length === 0 || !isModelInfo(modelInfo)) {
|
||||
return providerSettingsSelection
|
||||
return undefined
|
||||
}
|
||||
return { providerId, modelId, modelInfo }
|
||||
}
|
||||
@@ -424,21 +370,18 @@ function readSelectionFromState(providerId: ProviderId, mode: Mode): ModelSelect
|
||||
const activeProvider = mode === "plan" ? apiConfiguration.planModeApiProvider : apiConfiguration.actModeApiProvider
|
||||
const provider = providerForStorage(providerId)
|
||||
if (activeProvider !== provider) {
|
||||
return rememberedSelection ?? providerSettingsSelection
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (typeof modelId !== "string" || modelId.length === 0) {
|
||||
return rememberedSelection ?? providerSettingsSelection
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!isKnownModelIdForProvider(providerId, modelId)) {
|
||||
return rememberedSelection ?? providerSettingsSelection
|
||||
const modelInfo = readKnownModelInfoForProvider(providerId, modelId)
|
||||
if (!modelInfo) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!rememberedSelection || rememberedSelection.modelId !== modelId) {
|
||||
return providerSettingsSelection
|
||||
}
|
||||
return rememberedSelection
|
||||
return { providerId, modelId, modelInfo }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -469,7 +412,6 @@ export function createProviderConfigStore(): ProviderConfigStore {
|
||||
},
|
||||
|
||||
write(providerId: ProviderId, patch: ProviderConfigPatch): EffectiveProviderConfig {
|
||||
writeStateFields(providerId, patch)
|
||||
writeProviderSettingsFields(providerId, patch)
|
||||
const config = this.read(providerId)
|
||||
emit({ kind: "fields", providerId, config })
|
||||
@@ -477,7 +419,6 @@ export function createProviderConfigStore(): ProviderConfigStore {
|
||||
},
|
||||
|
||||
commitSelection(providerId: ProviderId, mode: Mode, selection: ModelSelection): void {
|
||||
writeSelectionToState(providerId, mode, selection)
|
||||
writeSelectionToProviderSettings(providerId, selection)
|
||||
emit({ kind: "selection", providerId, mode, selection })
|
||||
},
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
// Maps the extension's legacy SAP AI Core ApiConfiguration onto the SDK's
|
||||
// structured SAP provider options (baseUrl + sap block).
|
||||
//
|
||||
// buildSessionConfig() uses this to hand the SDK runtime the same structured
|
||||
// SAP fields that the legacy UI stores in ApiConfiguration.
|
||||
|
||||
import type { ProviderSettings } from "@cline/core"
|
||||
import type { ApiConfiguration } from "@shared/api"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
|
||||
export type SapProviderConfig = Pick<ProviderSettings, "baseUrl" | "sap">
|
||||
|
||||
function trimString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
export function buildSapProviderConfig(config: ApiConfiguration, mode: Mode): SapProviderConfig {
|
||||
const sap: NonNullable<SapProviderConfig["sap"]> = {}
|
||||
const baseUrl = trimString(config.sapAiCoreBaseUrl)
|
||||
const useOrchestrationMode = config.sapAiCoreUseOrchestrationMode ?? true
|
||||
const deploymentId = useOrchestrationMode
|
||||
? undefined
|
||||
: trimString(mode === "plan" ? config.planModeSapAiCoreDeploymentId : config.actModeSapAiCoreDeploymentId)
|
||||
const sapFields = {
|
||||
clientId: trimString(config.sapAiCoreClientId),
|
||||
clientSecret: trimString(config.sapAiCoreClientSecret),
|
||||
tokenUrl: trimString(config.sapAiCoreTokenUrl),
|
||||
resourceGroup: trimString(config.sapAiResourceGroup),
|
||||
deploymentId,
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(sapFields)) {
|
||||
if (value !== undefined) {
|
||||
sap[key as keyof typeof sapFields] = value
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(sap).length > 0) {
|
||||
sap.useOrchestrationMode = useOrchestrationMode
|
||||
}
|
||||
|
||||
return {
|
||||
...(baseUrl !== undefined ? { baseUrl } : {}),
|
||||
...(Object.keys(sap).length > 0 ? { sap } : {}),
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,16 @@ import { buildSdkProviderConfig } from "./sdk-api-handler"
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const providerSettingsManager = {
|
||||
getProviderConfig: vi.fn(),
|
||||
getProviderSettings: vi.fn(),
|
||||
}
|
||||
return {
|
||||
getProviderSettingsManager: vi.fn(() => providerSettingsManager),
|
||||
providerSettingsManager,
|
||||
stateManager: {
|
||||
getApiConfiguration: vi.fn(() => ({})),
|
||||
getRemoteConfigSettings: vi.fn(() => ({})),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -15,6 +20,12 @@ vi.mock("./provider-migration", () => ({
|
||||
getProviderSettingsManager: mocks.getProviderSettingsManager,
|
||||
}))
|
||||
|
||||
vi.mock("@/core/storage/StateManager", () => ({
|
||||
StateManager: {
|
||||
get: () => mocks.stateManager,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@shared/services/Logger", () => ({
|
||||
Logger: {
|
||||
warn: vi.fn(),
|
||||
@@ -24,6 +35,436 @@ vi.mock("@shared/services/Logger", () => ({
|
||||
describe("buildSdkProviderConfig", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.providerSettingsManager.getProviderConfig.mockReturnValue(undefined)
|
||||
mocks.providerSettingsManager.getProviderSettings.mockReturnValue(undefined)
|
||||
mocks.stateManager.getApiConfiguration.mockReturnValue({})
|
||||
mocks.stateManager.getRemoteConfigSettings.mockReturnValue({})
|
||||
})
|
||||
|
||||
it("prefers SDK provider config and overlays the mode-specific model", () => {
|
||||
mocks.providerSettingsManager.getProviderConfig.mockImplementation((providerId: string) => {
|
||||
if (providerId !== "bedrock") {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
providerId: "bedrock",
|
||||
modelId: "sdk-default",
|
||||
apiKey: "sdk-bedrock-key",
|
||||
region: "us-west-2",
|
||||
aws: {
|
||||
authentication: "apikey",
|
||||
customModelBaseId: "base-profile",
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const providerConfig = buildSdkProviderConfig(
|
||||
{
|
||||
actModeApiProvider: "bedrock",
|
||||
actModeApiModelId: "bedrock-model",
|
||||
awsBedrockApiKey: "legacy-bedrock-key",
|
||||
awsRegion: "us-east-1",
|
||||
},
|
||||
"act",
|
||||
)
|
||||
|
||||
expect(providerConfig).toMatchObject({
|
||||
providerId: "bedrock",
|
||||
modelId: "bedrock-model",
|
||||
apiKey: "sdk-bedrock-key",
|
||||
region: "us-west-2",
|
||||
aws: {
|
||||
authentication: "apikey",
|
||||
customModelBaseId: "base-profile",
|
||||
},
|
||||
})
|
||||
expect(mocks.providerSettingsManager.getProviderConfig).toHaveBeenCalledWith("bedrock", {
|
||||
includeKnownModels: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("fills missing persisted Bedrock settings from legacy state for standalone handlers", () => {
|
||||
mocks.stateManager.getApiConfiguration.mockReturnValue({
|
||||
awsRegion: "us-east-1",
|
||||
awsUseGlobalInference: true,
|
||||
})
|
||||
mocks.providerSettingsManager.getProviderConfig.mockImplementation((providerId: string) => {
|
||||
if (providerId !== "bedrock") {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
providerId: "bedrock",
|
||||
modelId: "sdk-default",
|
||||
apiKey: "sdk-bedrock-key",
|
||||
aws: {
|
||||
authentication: "api-key",
|
||||
},
|
||||
}
|
||||
})
|
||||
mocks.providerSettingsManager.getProviderSettings.mockImplementation((providerId: string) => {
|
||||
if (providerId !== "bedrock") {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
provider: "bedrock",
|
||||
apiKey: "sdk-bedrock-key",
|
||||
aws: {
|
||||
authentication: "api-key",
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const providerConfig = buildSdkProviderConfig(
|
||||
{
|
||||
actModeApiProvider: "bedrock",
|
||||
actModeApiModelId: "bedrock-model",
|
||||
awsBedrockApiKey: "legacy-bedrock-key",
|
||||
},
|
||||
"act",
|
||||
)
|
||||
|
||||
expect(providerConfig).toMatchObject({
|
||||
providerId: "bedrock",
|
||||
modelId: "bedrock-model",
|
||||
apiKey: "sdk-bedrock-key",
|
||||
region: "us-east-1",
|
||||
useGlobalInference: true,
|
||||
aws: {
|
||||
authentication: "api-key",
|
||||
region: "us-east-1",
|
||||
useGlobalInference: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("does not pass stale Bedrock API keys when legacy credentials auth maps to IAM", () => {
|
||||
mocks.stateManager.getApiConfiguration.mockReturnValue({
|
||||
awsAuthentication: "credentials",
|
||||
awsBedrockApiKey: "stale-bedrock-api-key",
|
||||
awsRegion: "us-east-1",
|
||||
})
|
||||
mocks.providerSettingsManager.getProviderConfig.mockImplementation((providerId: string) => {
|
||||
if (providerId !== "bedrock") {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
providerId: "bedrock",
|
||||
modelId: "sdk-default",
|
||||
apiKey: "persisted-stale-bedrock-api-key",
|
||||
aws: {
|
||||
authentication: "iam",
|
||||
},
|
||||
}
|
||||
})
|
||||
mocks.providerSettingsManager.getProviderSettings.mockImplementation((providerId: string) => {
|
||||
if (providerId !== "bedrock") {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
provider: "bedrock",
|
||||
apiKey: "persisted-stale-bedrock-api-key",
|
||||
aws: {
|
||||
authentication: "iam",
|
||||
},
|
||||
}
|
||||
})
|
||||
const providerConfig = buildSdkProviderConfig(
|
||||
{
|
||||
actModeApiProvider: "bedrock",
|
||||
actModeApiModelId: "bedrock-model",
|
||||
awsAuthentication: "credentials",
|
||||
awsBedrockApiKey: "stale-bedrock-api-key",
|
||||
awsRegion: "us-east-1",
|
||||
},
|
||||
"act",
|
||||
)
|
||||
|
||||
expect(providerConfig).toMatchObject({
|
||||
providerId: "bedrock",
|
||||
modelId: "bedrock-model",
|
||||
region: "us-east-1",
|
||||
aws: {
|
||||
authentication: "iam",
|
||||
region: "us-east-1",
|
||||
},
|
||||
})
|
||||
expect(providerConfig).not.toHaveProperty("apiKey")
|
||||
})
|
||||
|
||||
it("uses legacy OpenAI-compatible base URL when persisted settings only produce SDK defaults", () => {
|
||||
mocks.stateManager.getApiConfiguration.mockReturnValue({
|
||||
openAiBaseUrl: "http://localhost:8000/v1",
|
||||
azureApiVersion: "2025-01-01-preview",
|
||||
})
|
||||
mocks.providerSettingsManager.getProviderConfig.mockImplementation((providerId: string) => {
|
||||
if (providerId !== "openai-compatible") {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
providerId: "openai-compatible",
|
||||
modelId: "sdk-model",
|
||||
apiKey: "sdk-openai-compatible-key",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
}
|
||||
})
|
||||
mocks.providerSettingsManager.getProviderSettings.mockImplementation((providerId: string) => {
|
||||
if (providerId !== "openai-compatible") {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
provider: "openai-compatible",
|
||||
apiKey: "sdk-openai-compatible-key",
|
||||
}
|
||||
})
|
||||
|
||||
const providerConfig = buildSdkProviderConfig(
|
||||
{
|
||||
actModeApiProvider: "openai",
|
||||
actModeOpenAiModelId: "custom-chat-model",
|
||||
},
|
||||
"act",
|
||||
)
|
||||
|
||||
expect(providerConfig).toMatchObject({
|
||||
providerId: "openai-compatible",
|
||||
modelId: "custom-chat-model",
|
||||
apiKey: "sdk-openai-compatible-key",
|
||||
baseUrl: "http://localhost:8000/v1",
|
||||
azure: {
|
||||
apiVersion: "2025-01-01-preview",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("does not return stale persisted providerConfig reasoning fields for standalone handlers", () => {
|
||||
mocks.providerSettingsManager.getProviderConfig.mockImplementation((providerId: string) => {
|
||||
if (providerId !== "openai-compatible") {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
providerId: "openai-compatible",
|
||||
modelId: "sdk-model",
|
||||
apiKey: "sdk-openai-compatible-key",
|
||||
thinking: false,
|
||||
reasoningEffort: "high",
|
||||
thinkingBudgetTokens: 4096,
|
||||
}
|
||||
})
|
||||
mocks.providerSettingsManager.getProviderSettings.mockImplementation((providerId: string) => {
|
||||
if (providerId !== "openai-compatible") {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
provider: "openai-compatible",
|
||||
reasoning: {
|
||||
enabled: false,
|
||||
effort: "high",
|
||||
budgetTokens: 4096,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const providerConfig = buildSdkProviderConfig(
|
||||
{
|
||||
actModeApiProvider: "openai",
|
||||
actModeOpenAiModelId: "custom-chat-model",
|
||||
},
|
||||
"act",
|
||||
)
|
||||
|
||||
expect(providerConfig.thinking).toBeUndefined()
|
||||
expect(providerConfig.reasoningEffort).toBeUndefined()
|
||||
expect(providerConfig).not.toHaveProperty("thinkingBudgetTokens")
|
||||
})
|
||||
|
||||
it("overlays legacy OCA mode and base URL for standalone handlers", () => {
|
||||
mocks.stateManager.getApiConfiguration.mockReturnValue({
|
||||
ocaMode: "internal",
|
||||
ocaBaseUrl: "https://internal.oca.example/v1",
|
||||
})
|
||||
mocks.providerSettingsManager.getProviderConfig.mockImplementation((providerId: string) => {
|
||||
if (providerId !== "oca") {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
providerId: "oca",
|
||||
modelId: "sdk-oca-default",
|
||||
baseUrl: "https://code.aiservice.us-chicago-1.oci.oraclecloud.com/20250206/app/litellm",
|
||||
}
|
||||
})
|
||||
mocks.providerSettingsManager.getProviderSettings.mockImplementation((providerId: string) => {
|
||||
if (providerId !== "oca") {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
provider: "oca",
|
||||
baseUrl: "https://stale-migrated.oca.example/v1",
|
||||
oca: { mode: "external" },
|
||||
}
|
||||
})
|
||||
|
||||
const providerConfig = buildSdkProviderConfig(
|
||||
{
|
||||
actModeApiProvider: "oca",
|
||||
actModeOcaModelId: "anthropic/claude-3-7-sonnet-20250219",
|
||||
},
|
||||
"act",
|
||||
)
|
||||
|
||||
expect(providerConfig).toMatchObject({
|
||||
providerId: "oca",
|
||||
modelId: "anthropic/claude-3-7-sonnet-20250219",
|
||||
baseUrl: "https://internal.oca.example/v1",
|
||||
oca: {
|
||||
mode: "internal",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("fills Vertex runtime config from legacy state", () => {
|
||||
mocks.stateManager.getApiConfiguration.mockReturnValue({
|
||||
vertexProjectId: "legacy-project",
|
||||
vertexRegion: "us-central1",
|
||||
})
|
||||
const providerConfig = buildSdkProviderConfig(
|
||||
{
|
||||
actModeApiProvider: "vertex",
|
||||
actModeApiModelId: "gemini-2.5-pro",
|
||||
vertexProjectId: "legacy-project",
|
||||
vertexRegion: "us-central1",
|
||||
},
|
||||
"act",
|
||||
)
|
||||
|
||||
expect(providerConfig).toMatchObject({
|
||||
providerId: "vertex",
|
||||
modelId: "gemini-2.5-pro",
|
||||
region: "us-central1",
|
||||
gcp: {
|
||||
projectId: "legacy-project",
|
||||
region: "us-central1",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("prefers persisted Vertex SDK config while preserving the mode-selected model", () => {
|
||||
mocks.stateManager.getApiConfiguration.mockReturnValue({
|
||||
vertexProjectId: "legacy-project",
|
||||
vertexRegion: "us-central1",
|
||||
})
|
||||
mocks.providerSettingsManager.getProviderConfig.mockImplementation((providerId: string) => {
|
||||
if (providerId !== "vertex") {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
providerId: "vertex",
|
||||
modelId: "sdk-default",
|
||||
gcp: {
|
||||
projectId: "sdk-project",
|
||||
region: "europe-west4",
|
||||
},
|
||||
}
|
||||
})
|
||||
mocks.providerSettingsManager.getProviderSettings.mockImplementation((providerId: string) => {
|
||||
if (providerId !== "vertex") {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
provider: "vertex",
|
||||
gcp: {
|
||||
projectId: "sdk-project",
|
||||
region: "europe-west4",
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const providerConfig = buildSdkProviderConfig(
|
||||
{
|
||||
actModeApiProvider: "vertex",
|
||||
actModeApiModelId: "gemini-2.5-pro",
|
||||
vertexProjectId: "legacy-project",
|
||||
vertexRegion: "us-central1",
|
||||
},
|
||||
"act",
|
||||
)
|
||||
|
||||
expect(providerConfig).toMatchObject({
|
||||
providerId: "vertex",
|
||||
modelId: "gemini-2.5-pro",
|
||||
region: "europe-west4",
|
||||
gcp: {
|
||||
projectId: "sdk-project",
|
||||
region: "europe-west4",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("prefers act-mode SAP and Bedrock legacy fields over single persisted SDK values", () => {
|
||||
mocks.providerSettingsManager.getProviderConfig.mockImplementation((providerId: string) => {
|
||||
if (providerId === "sapaicore") {
|
||||
return {
|
||||
providerId: "sapaicore",
|
||||
modelId: "sdk-default",
|
||||
sap: { deploymentId: "persisted-deployment" },
|
||||
}
|
||||
}
|
||||
if (providerId === "bedrock") {
|
||||
return {
|
||||
providerId: "bedrock",
|
||||
modelId: "sdk-default",
|
||||
apiKey: "bedrock-key",
|
||||
aws: { authentication: "api-key", customModelBaseId: "persisted-base" },
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
mocks.providerSettingsManager.getProviderSettings.mockImplementation((providerId: string) => {
|
||||
if (providerId === "sapaicore") {
|
||||
return { provider: "sapaicore", sap: { deploymentId: "persisted-deployment" } }
|
||||
}
|
||||
if (providerId === "bedrock") {
|
||||
return {
|
||||
provider: "bedrock",
|
||||
apiKey: "bedrock-key",
|
||||
aws: { authentication: "api-key", customModelBaseId: "persisted-base" },
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
expect(
|
||||
buildSdkProviderConfig(
|
||||
{
|
||||
planModeApiProvider: "sapaicore",
|
||||
planModeSapAiCoreModelId: "anthropic--claude-3.5-sonnet",
|
||||
planModeSapAiCoreDeploymentId: "plan-deployment",
|
||||
actModeSapAiCoreDeploymentId: "act-deployment",
|
||||
},
|
||||
"plan",
|
||||
),
|
||||
).toMatchObject({
|
||||
providerId: "sapaicore",
|
||||
modelId: "anthropic--claude-3.5-sonnet",
|
||||
sap: { deploymentId: "act-deployment" },
|
||||
})
|
||||
expect(
|
||||
buildSdkProviderConfig(
|
||||
{
|
||||
planModeApiProvider: "bedrock",
|
||||
planModeApiModelId: "bedrock-model",
|
||||
planModeAwsBedrockCustomModelBaseId: "plan-base",
|
||||
actModeAwsBedrockCustomModelBaseId: "act-base",
|
||||
awsAuthentication: "api-key",
|
||||
awsBedrockApiKey: "bedrock-key",
|
||||
},
|
||||
"plan",
|
||||
),
|
||||
).toMatchObject({
|
||||
providerId: "bedrock",
|
||||
modelId: "bedrock-model",
|
||||
apiKey: "bedrock-key",
|
||||
aws: { authentication: "api-key", customModelBaseId: "act-base" },
|
||||
})
|
||||
})
|
||||
|
||||
it("uses shared Cline OAuth credentials for ClinePass direct handlers", () => {
|
||||
@@ -56,30 +497,18 @@ describe("buildSdkProviderConfig", () => {
|
||||
expect(mocks.providerSettingsManager.getProviderSettings).toHaveBeenCalledWith("cline")
|
||||
})
|
||||
|
||||
it("uses provider-specific settings for SDK-backed direct handlers", () => {
|
||||
mocks.providerSettingsManager.getProviderSettings.mockImplementation((providerId: string) => {
|
||||
if (providerId !== "v0") {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
provider: "v0",
|
||||
apiKey: "v0-key",
|
||||
}
|
||||
})
|
||||
|
||||
it("falls unsupported persisted providers back to the VS Code runtime default", () => {
|
||||
const providerConfig = buildSdkProviderConfig(
|
||||
{
|
||||
actModeApiProvider: "v0",
|
||||
actModeApiModelId: "v0-1.5-md",
|
||||
actModeApiProvider: "qwen-code",
|
||||
actModeApiModelId: "qwen-code-model",
|
||||
qwenApiKey: "qwen-code-key",
|
||||
},
|
||||
"act",
|
||||
)
|
||||
|
||||
expect(providerConfig).toMatchObject({
|
||||
providerId: "v0",
|
||||
modelId: "v0-1.5-md",
|
||||
apiKey: "v0-key",
|
||||
})
|
||||
expect(mocks.providerSettingsManager.getProviderSettings).toHaveBeenCalledWith("v0")
|
||||
expect(providerConfig.providerId).toBe("cline")
|
||||
expect(providerConfig.modelId).not.toBe("qwen-code-model")
|
||||
expect(providerConfig).not.toHaveProperty("apiKey", "qwen-code-key")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
// Replaces classic src/core/api buildApiHandler (see origin/main).
|
||||
//
|
||||
// Builds an SDK ApiHandler (from `@cline/llms`) directly from the extension's
|
||||
// legacy ApiConfiguration. This is the single inference path: the main task
|
||||
// loop runs through ClineCore (see cline-session-factory.ts), and standalone
|
||||
// utility callers (commit message generation) use the handler
|
||||
// returned here. Both share the same provider/model/key/baseUrl resolution so
|
||||
// Builds an SDK ApiHandler (from `@cline/llms`) from SDK provider settings.
|
||||
// The main task loop runs through ClineCore (see cline-session-factory.ts), and
|
||||
// standalone utility callers (commit message generation) use the handler
|
||||
// returned here. Both share the same SDK-first provider config resolution so
|
||||
// there is no second source of truth.
|
||||
|
||||
import { type ApiHandler, createHandler, type ProviderConfig } from "@cline/llms"
|
||||
import type { ApiConfiguration } from "@shared/api"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import { mirrorPlanActApiConfiguration } from "@/core/controller/models/sharedModeConfiguration"
|
||||
import { fetch } from "@/shared/net"
|
||||
import { buildBedrockProviderConfig } from "./bedrock-config"
|
||||
import { resolveApiKey, resolveBaseUrl, resolveModelId, resolveVertexProviderConfig } from "./cline-session-factory"
|
||||
import { toSdkProviderId } from "./model-catalog/sdk-provider-id"
|
||||
import { buildRuntimeProviderConfig } from "./cline-session-factory"
|
||||
|
||||
export interface BuildApiHandlerOptions {
|
||||
/**
|
||||
@@ -30,9 +28,10 @@ export interface BuildApiHandlerOptions {
|
||||
* Build an SDK `ProviderConfig` from the extension's `ApiConfiguration` for the
|
||||
* given mode (plan/act).
|
||||
*
|
||||
* Reuses the same resolvers the session factory uses to map the legacy config
|
||||
* onto provider id, model id, API key, and base URL, then converts the provider
|
||||
* id to the SDK's spelling (e.g. `openai` → `openai-compatible`).
|
||||
* Reads the SDK ProviderSettingsManager first, matching the CLI's ownership
|
||||
* model. Legacy ApiConfiguration is only a compatibility fallback for installs
|
||||
* that have not produced SDK provider settings yet. Plan/Act mode changes
|
||||
* runtime behavior and tool access, not the selected provider/model.
|
||||
*
|
||||
* Reasoning handling: the SDK gateway forwards `reasoningEffort` as
|
||||
* `reasoning.effort` and `thinkingBudgetTokens` as `reasoning.max_tokens`.
|
||||
@@ -45,36 +44,36 @@ export function buildSdkProviderConfig(
|
||||
mode: Mode,
|
||||
options?: BuildApiHandlerOptions,
|
||||
): ProviderConfig {
|
||||
const providerId = (mode === "plan" ? configuration.planModeApiProvider : configuration.actModeApiProvider) ?? "cline"
|
||||
|
||||
const apiKey = resolveApiKey(providerId, configuration)
|
||||
const modelId = resolveModelId(providerId, mode, configuration)
|
||||
const baseUrl = resolveBaseUrl(providerId, configuration)
|
||||
const sharedConfiguration = mirrorPlanActApiConfiguration(configuration)
|
||||
const providerId =
|
||||
(mode === "plan" ? sharedConfiguration.planModeApiProvider : sharedConfiguration.actModeApiProvider) ?? "cline"
|
||||
const runtimeProviderConfig = buildRuntimeProviderConfig(providerId, mode, sharedConfiguration)
|
||||
const modelId = runtimeProviderConfig.modelId ?? ""
|
||||
|
||||
const thinkingBudgetTokens =
|
||||
mode === "plan" ? configuration.planModeThinkingBudgetTokens : configuration.actModeThinkingBudgetTokens
|
||||
const reasoningEffort = mode === "plan" ? configuration.planModeReasoningEffort : configuration.actModeReasoningEffort
|
||||
|
||||
const vertexProviderConfig = providerId === "vertex" ? resolveVertexProviderConfig(configuration) : undefined
|
||||
mode === "plan" ? sharedConfiguration.planModeThinkingBudgetTokens : sharedConfiguration.actModeThinkingBudgetTokens
|
||||
const reasoningEffort =
|
||||
mode === "plan" ? sharedConfiguration.planModeReasoningEffort : sharedConfiguration.actModeReasoningEffort
|
||||
|
||||
const base: ProviderConfig = {
|
||||
providerId: toSdkProviderId(providerId),
|
||||
modelId: modelId ?? "",
|
||||
apiKey: apiKey ?? "",
|
||||
baseUrl,
|
||||
...(vertexProviderConfig ?? {}),
|
||||
...runtimeProviderConfig,
|
||||
modelId,
|
||||
// Use the proxy-aware fetch so gateway providers respect corporate proxy
|
||||
// configuration (see .clinerules/network.md).
|
||||
fetch,
|
||||
onRetryAttempt: configuration.onRetryAttempt,
|
||||
// Bedrock needs its region + structured AWS auth options forwarded to the
|
||||
// SDK gateway. Without these, a pasted Bedrock API key / region is dropped.
|
||||
...(providerId === "bedrock" ? buildBedrockProviderConfig(configuration, mode) : {}),
|
||||
}
|
||||
|
||||
if (options?.disableReasoning) {
|
||||
// Explicitly turn reasoning off; do not send effort or budget.
|
||||
return { ...base, thinking: false }
|
||||
const withoutReasoning = { ...base }
|
||||
delete withoutReasoning.reasoningEffort
|
||||
delete withoutReasoning.thinkingBudgetTokens
|
||||
return { ...withoutReasoning, thinking: false }
|
||||
}
|
||||
|
||||
if (runtimeProviderConfig.thinking !== undefined || runtimeProviderConfig.reasoningEffort !== undefined) {
|
||||
return base
|
||||
}
|
||||
|
||||
// Send at most one of budget/effort to avoid the "Only one of
|
||||
|
||||
@@ -83,6 +83,19 @@ describe("SdkProviderChangeCoordinator", () => {
|
||||
await vi.waitFor(() => expect(options.sessions.replaceActiveSession).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
it("restarts when current provider fields change through an SDK provider alias", async () => {
|
||||
const activeSession = makeActiveSession()
|
||||
const { coordinator, options } = makeCoordinator({
|
||||
activeSession,
|
||||
apiConfiguration: { actModeApiProvider: "openai" },
|
||||
})
|
||||
|
||||
coordinator.handleProviderConfigFieldsChanged("openai-compatible")
|
||||
|
||||
await vi.waitFor(() => expect(options.sessions.replaceActiveSession).toHaveBeenCalledOnce())
|
||||
expect(options.sessionConfigBuilder.build).toHaveBeenCalledWith({ cwd: "/workspace", mode: "act" })
|
||||
})
|
||||
|
||||
it("can clear a deferred restart before the session becomes idle", async () => {
|
||||
const activeSession = makeActiveSession({ isRunning: true })
|
||||
const { coordinator, options } = makeCoordinator({ activeSession })
|
||||
@@ -199,6 +212,7 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
|
||||
const options = {
|
||||
stateManager: {
|
||||
getGlobalSettingsKey: vi.fn(() => input.mode ?? "act"),
|
||||
getApiConfiguration: vi.fn(() => input.apiConfiguration ?? { actModeApiProvider: "anthropic" }),
|
||||
} as unknown as StateManager,
|
||||
sessions: {
|
||||
getActiveSession: vi.fn(() => activeSession),
|
||||
@@ -245,6 +259,7 @@ interface MakeCoordinatorInput {
|
||||
activeSession: ReturnType<typeof makeActiveSession>
|
||||
mode: "act" | "plan"
|
||||
task: { taskId: string }
|
||||
apiConfiguration: Record<string, unknown>
|
||||
}
|
||||
|
||||
function makeActiveSession(input: { isRunning?: boolean } = {}) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ApiConfiguration } from "@shared/api"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import type { StateManager } from "@/core/storage/StateManager"
|
||||
import { areProviderIdsEquivalent } from "@/shared/model-catalog/provider-helpers"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { SdkMessageCoordinator } from "./sdk-message-coordinator"
|
||||
import type { SdkModeCoordinator } from "./sdk-mode-coordinator"
|
||||
@@ -36,6 +37,31 @@ export class SdkProviderChangeCoordinator {
|
||||
|
||||
constructor(private readonly options: SdkProviderChangeCoordinatorOptions) {}
|
||||
|
||||
handleProviderConfigFieldsChanged(providerId: string): void {
|
||||
const mode = this.getCurrentMode()
|
||||
const activeProvider = providerForMode(this.options.stateManager.getApiConfiguration(), mode)
|
||||
if (!areProviderIdsEquivalent(activeProvider, providerId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const activeSession = this.options.sessions.getActiveSession()
|
||||
if (!activeSession) {
|
||||
Logger.log("[SdkController] Active provider config changed without active session; next task will use new config")
|
||||
return
|
||||
}
|
||||
|
||||
Logger.log(`[SdkController] Active provider config changed for ${mode}: ${providerId}`)
|
||||
if (activeSession.isRunning) {
|
||||
Logger.log("[SdkController] Session is mid-turn; deferring provider config restart")
|
||||
this.restartPending = true
|
||||
return
|
||||
}
|
||||
|
||||
this.restartActiveSessionForProviderChange().catch((error) => {
|
||||
Logger.error("[SdkController] Failed to restart session after provider config change:", error)
|
||||
})
|
||||
}
|
||||
|
||||
handleApiConfigurationChanged(previous: ApiConfiguration, next: ApiConfiguration): void {
|
||||
const mode = this.getCurrentMode()
|
||||
const previousProvider = providerForMode(previous, mode)
|
||||
|
||||
@@ -8,7 +8,6 @@ export enum ClineErrorType {
|
||||
SpendLimit = "spendLimit",
|
||||
QuotaExceeded = "quotaExceeded",
|
||||
Entitlement = "entitlement",
|
||||
OrgClinePassRestriction = "orgClinePassRestriction",
|
||||
}
|
||||
|
||||
interface ErrorDetails {
|
||||
@@ -45,8 +44,6 @@ interface ErrorDetails {
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
export class ClineError extends Error {
|
||||
readonly title = "ClineError"
|
||||
@@ -155,17 +152,10 @@ export class ClineError extends Error {
|
||||
return ClineErrorType.SpendLimit
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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(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
|
||||
}
|
||||
|
||||
@@ -54,27 +54,16 @@ describe("ClineError", () => {
|
||||
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
|
||||
})
|
||||
|
||||
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.
|
||||
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.
|
||||
const err = new ClineError({
|
||||
message: "403 Error 403: organization accounts cannot use individual model inference subscriptions",
|
||||
code: "ENTITLEMENT_ERROR",
|
||||
status: 403,
|
||||
})
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -54,7 +54,6 @@ export class FeatureFlagsService {
|
||||
flagKeys: FEATURE_FLAGS,
|
||||
})
|
||||
this.cacheInfo.flagsPayload = values
|
||||
Logger.log("Fetched Feature Flag values + " + JSON.stringify(values))
|
||||
|
||||
for (const flag of FEATURE_FLAGS) {
|
||||
const payload = await this.getFeatureFlag(flag).catch(() => false)
|
||||
@@ -85,10 +84,6 @@ export class FeatureFlagsService {
|
||||
})
|
||||
}
|
||||
|
||||
Logger.info(
|
||||
`[FeatureFlagsService] resolving ${flagName}: payload=${JSON.stringify(payload)} flagValue=${JSON.stringify(flagValue)} default=${JSON.stringify(FeatureFlagDefaultValue[flagName])} final=${JSON.stringify(value)} type=${typeof value}`,
|
||||
)
|
||||
|
||||
return value
|
||||
} catch (error) {
|
||||
Logger.error(`Error checking if feature flag ${flagName} is enabled:`, error)
|
||||
|
||||
@@ -33,22 +33,18 @@ export type ApiProvider =
|
||||
| "cerebras"
|
||||
| "sapaicore"
|
||||
| "groq"
|
||||
| "poolside"
|
||||
| "huggingface"
|
||||
| "huawei-cloud-maas"
|
||||
| "dify"
|
||||
| "baseten"
|
||||
| "vercel-ai-gateway"
|
||||
| "v0"
|
||||
| "zai"
|
||||
| "zai-coding-plan"
|
||||
| "oca"
|
||||
| "aihubmix"
|
||||
| "minimax"
|
||||
| "hicap"
|
||||
| "nousResearch"
|
||||
| "wandb"
|
||||
| "xiaomi"
|
||||
|
||||
export const DEFAULT_API_PROVIDER = "openrouter" as ApiProvider
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { toLegacyApiProvider } from "./provider-helpers"
|
||||
import {
|
||||
areProviderIdsEquivalent,
|
||||
isProviderAllowedByRemoteConfig,
|
||||
isVscodeUnsupportedProvider,
|
||||
toLegacyApiProvider,
|
||||
toVscodeSupportedProvider,
|
||||
} from "./provider-helpers"
|
||||
|
||||
describe("toLegacyApiProvider", () => {
|
||||
it("up-cases the nousResearch id to its legacy ApiConfiguration spelling", () => {
|
||||
@@ -7,9 +13,56 @@ describe("toLegacyApiProvider", () => {
|
||||
expect(toLegacyApiProvider("nousresearch")).toBe("nousResearch")
|
||||
})
|
||||
|
||||
it("maps the SDK OpenAI Compatible provider to the extension's legacy openai id", () => {
|
||||
expect(toLegacyApiProvider("openai-compatible")).toBe("openai")
|
||||
})
|
||||
|
||||
it("passes through other ids unchanged", () => {
|
||||
expect(toLegacyApiProvider("deepseek")).toBe("deepseek")
|
||||
expect(toLegacyApiProvider("anthropic")).toBe("anthropic")
|
||||
expect(toLegacyApiProvider("openai-codex")).toBe("openai-codex")
|
||||
expect(toLegacyApiProvider("openai-native")).toBe("openai-native")
|
||||
})
|
||||
})
|
||||
|
||||
describe("areProviderIdsEquivalent", () => {
|
||||
it("treats SDK and legacy aliases as the same provider", () => {
|
||||
expect(areProviderIdsEquivalent("openai", "openai-compatible")).toBe(true)
|
||||
expect(areProviderIdsEquivalent("openai-compatible", "openai")).toBe(true)
|
||||
expect(areProviderIdsEquivalent("nousresearch", "nousResearch")).toBe(true)
|
||||
})
|
||||
|
||||
it("does not match unrelated or missing providers", () => {
|
||||
expect(areProviderIdsEquivalent("openai", "openai-native")).toBe(false)
|
||||
expect(areProviderIdsEquivalent("anthropic", undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isProviderAllowedByRemoteConfig", () => {
|
||||
it("matches remote configured providers through SDK and legacy aliases", () => {
|
||||
expect(isProviderAllowedByRemoteConfig("openai", ["openai-compatible"])).toBe(true)
|
||||
expect(isProviderAllowedByRemoteConfig("openai-compatible", ["openai"])).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects unrelated and missing providers", () => {
|
||||
expect(isProviderAllowedByRemoteConfig("openai", ["anthropic"])).toBe(false)
|
||||
expect(isProviderAllowedByRemoteConfig(undefined, ["openai-compatible"])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("VS Code provider support helpers", () => {
|
||||
it("identifies providers VS Code intentionally hides until host auth is implemented", () => {
|
||||
expect(isVscodeUnsupportedProvider("claude-code")).toBe(true)
|
||||
expect(isVscodeUnsupportedProvider("qwen-code")).toBe(true)
|
||||
expect(isVscodeUnsupportedProvider("dify")).toBe(true)
|
||||
expect(isVscodeUnsupportedProvider("openai-codex")).toBe(false)
|
||||
})
|
||||
|
||||
it("falls unsupported or missing providers back to the VS Code default", () => {
|
||||
expect(toVscodeSupportedProvider("qwen-code")).toBe("cline")
|
||||
expect(toVscodeSupportedProvider("dify")).toBe("cline")
|
||||
expect(toVscodeSupportedProvider(undefined)).toBe("cline")
|
||||
expect(toVscodeSupportedProvider("openai-compatible")).toBe("openai")
|
||||
expect(toVscodeSupportedProvider("deepseek")).toBe("deepseek")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { ApiProvider } from "@shared/api"
|
||||
|
||||
export const VSCODE_DEFAULT_PROVIDER_ID: ApiProvider = "cline"
|
||||
const VSCODE_UNSUPPORTED_PROVIDER_IDS = new Set(["claude-code", "qwen-code", "dify"])
|
||||
|
||||
/**
|
||||
* Convert SDK/catalog provider ids to the legacy `ApiProvider` spelling used
|
||||
* by `ApiConfiguration` keys. Most ids are identical; this helper remains as
|
||||
@@ -8,8 +11,42 @@ import type { ApiProvider } from "@shared/api"
|
||||
const SDK_PROVIDER_ID_TO_LEGACY_API_PROVIDER: Partial<Record<string, ApiProvider>> = {
|
||||
nousResearch: "nousResearch",
|
||||
nousresearch: "nousResearch",
|
||||
"openai-compatible": "openai",
|
||||
} satisfies Partial<Record<string, ApiProvider>>
|
||||
|
||||
export function toLegacyApiProvider(providerId: string): ApiProvider {
|
||||
return SDK_PROVIDER_ID_TO_LEGACY_API_PROVIDER[providerId] ?? (providerId as ApiProvider)
|
||||
}
|
||||
|
||||
export function isVscodeUnsupportedProvider(providerId: string | undefined): boolean {
|
||||
return providerId ? VSCODE_UNSUPPORTED_PROVIDER_IDS.has(providerId) : false
|
||||
}
|
||||
|
||||
export function toVscodeSupportedProvider(
|
||||
providerId: string | undefined,
|
||||
fallback: ApiProvider = VSCODE_DEFAULT_PROVIDER_ID,
|
||||
): ApiProvider {
|
||||
if (!providerId || isVscodeUnsupportedProvider(providerId)) {
|
||||
return fallback
|
||||
}
|
||||
return toLegacyApiProvider(providerId)
|
||||
}
|
||||
|
||||
export function areProviderIdsEquivalent(left: string | undefined, right: string | undefined): boolean {
|
||||
if (!left || !right) {
|
||||
return false
|
||||
}
|
||||
|
||||
return left === right || toLegacyApiProvider(left) === toLegacyApiProvider(right)
|
||||
}
|
||||
|
||||
export function isProviderAllowedByRemoteConfig(
|
||||
provider: string | undefined,
|
||||
remoteConfiguredProviders: readonly string[],
|
||||
): boolean {
|
||||
if (!provider) {
|
||||
return false
|
||||
}
|
||||
|
||||
return remoteConfiguredProviders.some((configuredProvider) => areProviderIdsEquivalent(provider, configuredProvider))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ApiProvider } from "@shared/api"
|
||||
import type { RemoteConfigFields } from "@shared/storage/state-keys"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { getRemoteLockedProviderFieldPaths } from "./remote-config-locks"
|
||||
|
||||
describe("getRemoteLockedProviderFieldPaths", () => {
|
||||
it("locks LiteLLM API key writes when the key is remote configured", () => {
|
||||
const locked = getRemoteLockedProviderFieldPaths(
|
||||
{
|
||||
remoteConfiguredProviders: ["litellm"],
|
||||
configuredApiKeys: { litellm: true },
|
||||
},
|
||||
"litellm",
|
||||
)
|
||||
|
||||
expect(locked).toEqual(new Set(["apiKey"]))
|
||||
})
|
||||
|
||||
const cases: Array<{
|
||||
name: string
|
||||
remoteConfig: Partial<RemoteConfigFields>
|
||||
providerId: string
|
||||
expected: string[]
|
||||
}> = [
|
||||
{
|
||||
name: "OpenAI-compatible alias fields",
|
||||
remoteConfig: {
|
||||
remoteConfiguredProviders: ["openai-compatible" as ApiProvider],
|
||||
openAiBaseUrl: "https://remote.example/v1",
|
||||
openAiHeaders: { "x-remote": "locked" },
|
||||
azureApiVersion: "2026-01-01-preview",
|
||||
},
|
||||
providerId: "openai",
|
||||
expected: ["baseUrl", "headers", "azure.apiVersion"],
|
||||
},
|
||||
{
|
||||
name: "Bedrock nested AWS fields",
|
||||
remoteConfig: {
|
||||
remoteConfiguredProviders: ["bedrock"],
|
||||
awsRegion: "us-east-1",
|
||||
awsUseCrossRegionInference: true,
|
||||
awsUseGlobalInference: true,
|
||||
awsBedrockEndpoint: "https://bedrock.example",
|
||||
},
|
||||
providerId: "bedrock",
|
||||
expected: ["region", "aws.region", "aws.useCrossRegionInference", "aws.useGlobalInference", "aws.endpoint"],
|
||||
},
|
||||
{
|
||||
name: "Vertex project and region fields",
|
||||
remoteConfig: {
|
||||
remoteConfiguredProviders: ["vertex"],
|
||||
vertexProjectId: "remote-project",
|
||||
vertexRegion: "us-central1",
|
||||
},
|
||||
providerId: "vertex",
|
||||
expected: ["gcp.projectId", "region", "gcp.region"],
|
||||
},
|
||||
]
|
||||
|
||||
it.each(cases)("locks $name", ({ remoteConfig, providerId, expected }) => {
|
||||
const locked = getRemoteLockedProviderFieldPaths(remoteConfig, providerId)
|
||||
|
||||
expect(locked).toEqual(new Set(expected))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { RemoteConfigFields } from "@shared/storage/state-keys"
|
||||
import { areProviderIdsEquivalent } from "./provider-helpers"
|
||||
|
||||
type RemoteConfigKey = keyof RemoteConfigFields
|
||||
|
||||
const REMOTE_LOCKED_FIELD_PATHS: Record<string, Partial<Record<RemoteConfigKey, readonly string[]>>> = {
|
||||
anthropic: {
|
||||
anthropicBaseUrl: ["baseUrl"],
|
||||
},
|
||||
bedrock: {
|
||||
awsRegion: ["region", "aws.region"],
|
||||
awsUseCrossRegionInference: ["aws.useCrossRegionInference"],
|
||||
awsUseGlobalInference: ["aws.useGlobalInference"],
|
||||
awsBedrockUsePromptCache: ["aws.usePromptCache"],
|
||||
awsBedrockEndpoint: ["aws.endpoint"],
|
||||
},
|
||||
litellm: {
|
||||
configuredApiKeys: ["apiKey"],
|
||||
liteLlmBaseUrl: ["baseUrl"],
|
||||
},
|
||||
openai: {
|
||||
openAiBaseUrl: ["baseUrl"],
|
||||
openAiHeaders: ["headers"],
|
||||
azureApiVersion: ["azure.apiVersion"],
|
||||
},
|
||||
vertex: {
|
||||
vertexProjectId: ["gcp.projectId"],
|
||||
vertexRegion: ["region", "gcp.region"],
|
||||
},
|
||||
}
|
||||
|
||||
function canonicalRemoteProviderId(providerId: string): string {
|
||||
return areProviderIdsEquivalent(providerId, "openai-compatible") ? "openai" : providerId
|
||||
}
|
||||
|
||||
export function getRemoteLockedProviderFieldPaths(
|
||||
remoteConfigSettings: Partial<RemoteConfigFields> | undefined,
|
||||
providerId: string,
|
||||
): Set<string> {
|
||||
const locked = new Set<string>()
|
||||
const configuredProviders = remoteConfigSettings?.remoteConfiguredProviders ?? []
|
||||
if (!configuredProviders.some((configuredProvider) => areProviderIdsEquivalent(providerId, configuredProvider))) {
|
||||
return locked
|
||||
}
|
||||
|
||||
const fieldMap = REMOTE_LOCKED_FIELD_PATHS[canonicalRemoteProviderId(providerId)]
|
||||
if (!fieldMap) {
|
||||
return locked
|
||||
}
|
||||
|
||||
for (const [key, paths] of Object.entries(fieldMap) as Array<[RemoteConfigKey, readonly string[]]>) {
|
||||
if (remoteConfigSettings?.[key] !== undefined) {
|
||||
for (const path of paths) {
|
||||
locked.add(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return locked
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { ApiProvider } from "@shared/api"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { convertApiConfigurationToProto, convertProtoToApiConfiguration } from "./api-configuration-conversion"
|
||||
|
||||
describe("api configuration provider conversion", () => {
|
||||
it("round-trips SDK provider ids added after the legacy enum list", () => {
|
||||
const providers: ApiProvider[] = ["poolside", "v0", "xiaomi", "zai-coding-plan"]
|
||||
|
||||
for (const provider of providers) {
|
||||
const proto = convertApiConfigurationToProto({
|
||||
actModeApiProvider: provider,
|
||||
planModeApiProvider: provider,
|
||||
})
|
||||
|
||||
// Assert field-by-field instead of toMatchObject: this file is also picked up by
|
||||
// the mocha integration runner (.vscode-test.mjs globs src/shared/**/*.test.js),
|
||||
// where vitest's jest-compat matchers like toMatchObject are not available.
|
||||
const result = convertProtoToApiConfiguration(proto)
|
||||
expect(result.actModeApiProvider).toBe(provider)
|
||||
expect(result.planModeApiProvider).toBe(provider)
|
||||
}
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user