mirror of
https://github.com/cline/cline.git
synced 2026-10-24 12:03:46 +08:00
Merge remote-tracking branch 'origin/main' into harrison/simplify-pr-15095-dcf169
This commit is contained in:
@@ -21,7 +21,6 @@ sidecar/
|
||||
├── chat-session.ts # Shared-Hub chat session adapter (local + cloud routing)
|
||||
├── cloud-sessions.ts # Cloud session REST client + Hub-proxy manager
|
||||
├── cline-auth.ts # Refresh-aware Cline auth token resolution
|
||||
├── desktop-settings.ts # Desktop-owned settings (cloud sessions opt-in)
|
||||
├── feature-flags.ts # Cloud sessions gate (env override + settings toggle)
|
||||
├── session-data/ # Shared discovery, messages, artifacts, search helpers
|
||||
├── paths.ts # Path resolution
|
||||
@@ -212,9 +211,7 @@ Supported commands:
|
||||
| `read_session_hooks` | Session data readers |
|
||||
| `delete_chat_session` | `SqliteSessionStore.delete` + file cleanup; cloud sessions also delete the sandbox |
|
||||
| `update_chat_session_title` | `resolveSessionBackend().updateSession`; cloud sessions PATCH the cloud API |
|
||||
| `get_feature_flags` | `isCloudAgentsEnabled()` (env override + settings toggle) |
|
||||
| `get_desktop_settings` | `readDesktopSettings()` |
|
||||
| `set_cloud_sessions_enabled` | `setCloudSessionsEnabled()` + `feature_flags_changed` broadcast |
|
||||
| `get_feature_flags` | PostHog flag snapshot + `isCloudAgentsEnabled()` (env override + `code-cloud-agents` rollout flag) |
|
||||
| `list_cloud_repositories` | `CloudSessionManager.listRepositories()` (GitHub integration) |
|
||||
| `list_cloud_models` | `CloudSessionManager.listModels()` (account-scoped Usage-Billing, ClinePass, and ClineFree catalogs; model IDs use the Cline cloud transport) |
|
||||
| `list_cloud_branches` | `CloudSessionManager.listBranches()` (paginated) |
|
||||
|
||||
@@ -9,7 +9,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getCloudSessionManager } from "./cloud-sessions";
|
||||
import { handleCommand } from "./commands";
|
||||
import { createSidecarContext } from "./context";
|
||||
import { setCloudSessionsEnabled } from "./desktop-settings";
|
||||
import {
|
||||
getDesktopFeatureFlagsService,
|
||||
resetDesktopFeatureFlagsForTesting,
|
||||
@@ -79,7 +78,7 @@ describe("desktop settings commands", () => {
|
||||
"list_chat_sessions",
|
||||
"list_discovered_sessions",
|
||||
"search_sessions",
|
||||
])("hides cloud rows from %s unless rollout and user toggle are enabled", async (command) => {
|
||||
])("hides cloud rows from %s unless the rollout flag is enabled", async (command) => {
|
||||
const { ctx } = createContext();
|
||||
for (const target of ["local", "cloud"] as const) {
|
||||
ctx.liveSessions.set(target, {
|
||||
@@ -113,16 +112,13 @@ describe("desktop settings commands", () => {
|
||||
.map((row) => row.sessionId)
|
||||
.sort();
|
||||
|
||||
expect(await rows()).toEqual(["local"]);
|
||||
expect(discovery).not.toHaveBeenCalled();
|
||||
setCloudSessionsEnabled(true);
|
||||
expect(await rows()).toEqual(["cloud", "local"]);
|
||||
discovery.mockImplementationOnce(async () => {
|
||||
setCloudSessionsEnabled(false);
|
||||
process.env.CLINE_CODE_CLOUD_AGENTS = "0";
|
||||
return cloudRows;
|
||||
});
|
||||
expect(await rows()).toEqual(["local"]);
|
||||
setCloudSessionsEnabled(true);
|
||||
delete process.env.CLINE_CODE_CLOUD_AGENTS;
|
||||
discovery.mockClear();
|
||||
flags.hydrateCache({
|
||||
userId: null,
|
||||
@@ -172,45 +168,4 @@ describe("desktop settings commands", () => {
|
||||
expect.objectContaining({ id: "cline-cloud/cloud-only" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a non-boolean cloud sessions toggle value", async () => {
|
||||
const { ctx, events } = createContext();
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "set_cloud_sessions_enabled", {
|
||||
cloud_sessions_enabled: "yes",
|
||||
}),
|
||||
).rejects.toThrow("cloud_sessions_enabled must be a boolean");
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
true,
|
||||
false,
|
||||
])("persists toggle %s and refreshes cloud history immediately", async (enabled) => {
|
||||
const { ctx, events } = createContext();
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "set_cloud_sessions_enabled", {
|
||||
cloud_sessions_enabled: enabled,
|
||||
}),
|
||||
).resolves.toEqual({ cloudSessionsEnabled: enabled });
|
||||
expect(events).toEqual([
|
||||
{ name: "cloud_sessions_changed", payload: { environmentId: "local" } },
|
||||
{
|
||||
name: "feature_flags_changed",
|
||||
payload: {
|
||||
cloudAgents: false,
|
||||
cloudAgentsAvailable: false,
|
||||
environmentId: "local",
|
||||
},
|
||||
},
|
||||
]);
|
||||
await expect(
|
||||
handleCommand(ctx, "get_feature_flags", {}),
|
||||
).resolves.toMatchObject({ cloudAgents: false });
|
||||
await expect(
|
||||
handleCommand(ctx, "get_desktop_settings", {}),
|
||||
).resolves.toEqual({ cloudSessionsEnabled: enabled });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,14 +126,9 @@ import {
|
||||
resolveSidecarAskQuestion,
|
||||
sendEventToClient,
|
||||
} from "./context";
|
||||
import {
|
||||
readDesktopSettings,
|
||||
setCloudSessionsEnabled,
|
||||
} from "./desktop-settings";
|
||||
import { writeDiagnosticsReport } from "./diagnostics";
|
||||
import {
|
||||
identifyDesktopFeatureFlagsAccount,
|
||||
isCloudAgentsAvailable,
|
||||
isCloudAgentsEnabled,
|
||||
refreshDesktopFeatureFlags,
|
||||
} from "./feature-flags";
|
||||
@@ -2208,8 +2203,6 @@ export async function handleCommand(
|
||||
};
|
||||
}
|
||||
if (command === "get_feature_flags") {
|
||||
// Resolve flags in the sidecar: available shows the opt-in;
|
||||
// enabled requires both the rollout flag and the user's opt-in.
|
||||
const snapshot = await refreshDesktopFeatureFlags({
|
||||
logger: ctx.logger,
|
||||
telemetry: ctx.telemetry,
|
||||
@@ -2220,10 +2213,6 @@ export async function handleCommand(
|
||||
logger: ctx.logger,
|
||||
telemetry: ctx.telemetry,
|
||||
}),
|
||||
cloudAgentsAvailable: isCloudAgentsAvailable({
|
||||
logger: ctx.logger,
|
||||
telemetry: ctx.telemetry,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (command === "list_cloud_models") {
|
||||
@@ -3222,9 +3211,6 @@ export async function handleCommand(
|
||||
setAutoUpdateEnabledGlobally(args.auto_update_enabled);
|
||||
return readGlobalSettings();
|
||||
}
|
||||
if (command === "get_desktop_settings") {
|
||||
return readDesktopSettings();
|
||||
}
|
||||
if (command === "export_diagnostics") {
|
||||
const sessionIds = Array.isArray(args?.sessionIds)
|
||||
? args.sessionIds.filter(
|
||||
@@ -3235,18 +3221,6 @@ export async function handleCommand(
|
||||
openFileInEditor(dirname(result.path));
|
||||
return result;
|
||||
}
|
||||
if (command === "set_cloud_sessions_enabled") {
|
||||
if (typeof args?.cloud_sessions_enabled !== "boolean") {
|
||||
throw new Error("cloud_sessions_enabled must be a boolean");
|
||||
}
|
||||
const settings = setCloudSessionsEnabled(args.cloud_sessions_enabled);
|
||||
broadcastEvent(ctx, "cloud_sessions_changed", {});
|
||||
broadcastEvent(ctx, "feature_flags_changed", {
|
||||
cloudAgents: isCloudAgentsEnabled(),
|
||||
cloudAgentsAvailable: isCloudAgentsAvailable(),
|
||||
});
|
||||
return settings;
|
||||
}
|
||||
|
||||
// ── Connector channels ─────────────────────────────────────────────
|
||||
if (command === "list_connector_channels") {
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
readDesktopSettings,
|
||||
resolveDesktopSettingsPath,
|
||||
setCloudSessionsEnabled,
|
||||
} from "./desktop-settings";
|
||||
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), "cline-desktop-settings-"));
|
||||
process.env.CLINE_DATA_DIR = dataDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.CLINE_DATA_DIR;
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("desktop settings", () => {
|
||||
it("defaults cloud sessions to off when no settings file exists", () => {
|
||||
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
|
||||
});
|
||||
|
||||
it("persists the cloud sessions opt-in and reads it back", () => {
|
||||
expect(setCloudSessionsEnabled(true)).toEqual({
|
||||
cloudSessionsEnabled: true,
|
||||
});
|
||||
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: true });
|
||||
expect(resolveDesktopSettingsPath().endsWith("code-settings.json")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
JSON.parse(readFileSync(resolveDesktopSettingsPath(), "utf8")),
|
||||
).toMatchObject({ cloudSessionsEnabled: true });
|
||||
expect(setCloudSessionsEnabled(false)).toEqual({
|
||||
cloudSessionsEnabled: false,
|
||||
});
|
||||
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
|
||||
});
|
||||
|
||||
it("treats malformed files and non-boolean values as off", () => {
|
||||
mkdirSync(dirname(resolveDesktopSettingsPath()), { recursive: true });
|
||||
writeFileSync(resolveDesktopSettingsPath(), "{not json", "utf8");
|
||||
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
|
||||
writeFileSync(
|
||||
resolveDesktopSettingsPath(),
|
||||
JSON.stringify({ cloudSessionsEnabled: "yes" }),
|
||||
"utf8",
|
||||
);
|
||||
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { resolveClineDataDir } from "@cline/shared/storage";
|
||||
|
||||
/** Desktop-only preferences kept separate from strict shared global settings. */
|
||||
export type DesktopSettings = {
|
||||
/** Opt-in gate for cloud sessions while the feature is in preview. */
|
||||
cloudSessionsEnabled: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_SETTINGS: DesktopSettings = {
|
||||
cloudSessionsEnabled: false,
|
||||
};
|
||||
|
||||
export function resolveDesktopSettingsPath(): string {
|
||||
return join(resolveClineDataDir(), "settings", "code-settings.json");
|
||||
}
|
||||
|
||||
export function readDesktopSettings(): DesktopSettings {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(resolveDesktopSettingsPath(), "utf8");
|
||||
} catch {
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
return {
|
||||
cloudSessionsEnabled: parsed.cloudSessionsEnabled === true,
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
}
|
||||
|
||||
export function writeDesktopSettings(settings: DesktopSettings): void {
|
||||
const filePath = resolveDesktopSettingsPath();
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
// Avoid leaving torn settings if the process exits mid-write.
|
||||
const tempPath = `${filePath}.${process.pid}.tmp`;
|
||||
writeFileSync(tempPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
||||
renameSync(tempPath, filePath);
|
||||
}
|
||||
|
||||
export function setCloudSessionsEnabled(enabled: boolean): DesktopSettings {
|
||||
const next = { ...readDesktopSettings(), cloudSessionsEnabled: enabled };
|
||||
writeDesktopSettings(next);
|
||||
return next;
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import { arch, homedir, platform, release } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { readGlobalSettings, resolveClineDataDir } from "@cline/core";
|
||||
import packageJson from "../package.json";
|
||||
import { readDesktopSettings } from "./desktop-settings";
|
||||
import { readSessionManifest } from "./paths";
|
||||
|
||||
/** How much of each log file (from the end) is included in the report. */
|
||||
@@ -88,7 +87,6 @@ export function buildDiagnosticsReport(
|
||||
},
|
||||
dataDir,
|
||||
globalSettings: readGlobalSettings(),
|
||||
desktopSettings: readDesktopSettings(),
|
||||
};
|
||||
let text = `${JSON.stringify(report, null, 2)}\n`;
|
||||
|
||||
|
||||
@@ -337,45 +337,32 @@ describe("cloud agents gate", () => {
|
||||
delete process.env.CLINE_CODE_CLOUD_AGENTS;
|
||||
});
|
||||
|
||||
it("is unavailable and disabled while the rollout flag is off", async () => {
|
||||
const { isCloudAgentsAvailable, isCloudAgentsEnabled } = await import(
|
||||
"./feature-flags"
|
||||
);
|
||||
expect(isCloudAgentsAvailable()).toBe(false);
|
||||
it("is disabled while the rollout flag is off", async () => {
|
||||
const { isCloudAgentsEnabled } = await import("./feature-flags");
|
||||
expect(isCloudAgentsEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("does not enable a boolean rollout from a truthy variant payload", async () => {
|
||||
const { isCloudAgentsAvailable } = await import("./feature-flags");
|
||||
const { isCloudAgentsEnabled } = await import("./feature-flags");
|
||||
mocks.getFlagPayload.mockReturnValue("control");
|
||||
expect(isCloudAgentsAvailable()).toBe(false);
|
||||
expect(isCloudAgentsEnabled()).toBe(false);
|
||||
expect(mocks.getBooleanFlagEnabled).toHaveBeenCalledWith(
|
||||
"code-cloud-agents",
|
||||
);
|
||||
});
|
||||
|
||||
it("needs both the rollout flag and the user's opt-in to enable", async () => {
|
||||
const { isCloudAgentsEnabled, isCloudAgentsAvailable } = await import(
|
||||
"./feature-flags"
|
||||
);
|
||||
const { setCloudSessionsEnabled } = await import("./desktop-settings");
|
||||
it("is enabled by the rollout flag alone", async () => {
|
||||
const { isCloudAgentsEnabled } = await import("./feature-flags");
|
||||
mocks.getBooleanFlagEnabled.mockImplementation(
|
||||
(flag: unknown) => flag === "code-cloud-agents",
|
||||
);
|
||||
expect(isCloudAgentsAvailable()).toBe(true);
|
||||
setCloudSessionsEnabled(false);
|
||||
expect(isCloudAgentsEnabled()).toBe(false);
|
||||
setCloudSessionsEnabled(true);
|
||||
expect(isCloudAgentsEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it("lets the env override force the gate in both directions", async () => {
|
||||
const { isCloudAgentsEnabled, isCloudAgentsAvailable } = await import(
|
||||
"./feature-flags"
|
||||
);
|
||||
const { isCloudAgentsEnabled } = await import("./feature-flags");
|
||||
mocks.getFlagPayload.mockReturnValue(undefined);
|
||||
process.env.CLINE_CODE_CLOUD_AGENTS = "1";
|
||||
expect(isCloudAgentsAvailable()).toBe(true);
|
||||
expect(isCloudAgentsEnabled()).toBe(true);
|
||||
process.env.CLINE_CODE_CLOUD_AGENTS = "0";
|
||||
expect(isCloudAgentsEnabled()).toBe(false);
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
} from "@cline/core/services/feature-flags/posthog";
|
||||
import { FeatureFlag as SharedFeatureFlag } from "@cline/shared";
|
||||
import { resolveClineDataDir } from "@cline/shared/storage";
|
||||
import { readDesktopSettings } from "./desktop-settings";
|
||||
|
||||
const FEATURE_FLAG_CODE_CLOUD_AGENTS = SharedFeatureFlag.CODE_CLOUD_AGENTS;
|
||||
|
||||
@@ -282,8 +281,8 @@ export function readCloudAgentsEnvOverride(): boolean | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Whether the rollout makes cloud sessions available to this install. */
|
||||
export function isCloudAgentsAvailable(options?: {
|
||||
/** Whether the rollout enables cloud sessions for this install. */
|
||||
export function isCloudAgentsEnabled(options?: {
|
||||
logger?: BasicLogger;
|
||||
telemetry?: ITelemetryService;
|
||||
}): boolean {
|
||||
@@ -293,14 +292,3 @@ export function isCloudAgentsAvailable(options?: {
|
||||
FEATURE_FLAG_CODE_CLOUD_AGENTS,
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether cloud sessions are both available and enabled by the user. */
|
||||
export function isCloudAgentsEnabled(options?: {
|
||||
logger?: BasicLogger;
|
||||
telemetry?: ITelemetryService;
|
||||
}): boolean {
|
||||
const override = readCloudAgentsEnvOverride();
|
||||
if (override !== undefined) return override;
|
||||
if (!isCloudAgentsAvailable(options)) return false;
|
||||
return readDesktopSettings().cloudSessionsEnabled;
|
||||
}
|
||||
|
||||
@@ -1277,20 +1277,8 @@ function ChatThreadPane({
|
||||
});
|
||||
};
|
||||
fetchFlags();
|
||||
// Reflect Settings changes without restarting or switching accounts.
|
||||
const unsubscribe = desktopClient.subscribe(
|
||||
"feature_flags_changed",
|
||||
(payload) => {
|
||||
if (!cancelled) {
|
||||
setCloudAgentsFlagEnabled(
|
||||
Boolean((payload as { cloudAgents?: boolean })?.cloudAgents),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
if (retryTimer !== undefined) window.clearTimeout(retryTimer);
|
||||
};
|
||||
}, [accountUserId]);
|
||||
|
||||
@@ -112,52 +112,3 @@ describe("SettingsView font size", () => {
|
||||
expect(increaseButton?.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsView cloud sessions rollout", () => {
|
||||
it.each([
|
||||
{
|
||||
caseName: "the rollout explicitly enables it",
|
||||
featureFlags: { cloudAgents: false, cloudAgentsAvailable: true },
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
caseName: "feature flags are unavailable",
|
||||
featureFlags: new Error("feature flags unavailable"),
|
||||
visible: false,
|
||||
},
|
||||
])("shows the preview setting only when $caseName", async ({
|
||||
featureFlags,
|
||||
visible,
|
||||
}) => {
|
||||
invoke.mockImplementation(async (command: string) => {
|
||||
if (command === "get_feature_flags") {
|
||||
if (featureFlags instanceof Error) throw featureFlags;
|
||||
return featureFlags;
|
||||
}
|
||||
if (command === "get_desktop_settings") {
|
||||
return { cloudSessionsEnabled: false };
|
||||
}
|
||||
return {
|
||||
telemetryOptOut: false,
|
||||
autoUpdateEnabled: true,
|
||||
};
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SettingsView
|
||||
onExportDiagnostics={vi.fn()}
|
||||
onNavigateSection={vi.fn()}
|
||||
section="General"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
container.querySelector(
|
||||
'[role="switch"][aria-label="Cloud sessions"]',
|
||||
) !== null,
|
||||
).toBe(visible),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -699,32 +699,6 @@ function GeneralSettingsContent({
|
||||
const [autoUpdateLoading, setAutoUpdateLoading] = useState(true);
|
||||
const [autoUpdateSaving, setAutoUpdateSaving] = useState(false);
|
||||
const [autoUpdateError, setAutoUpdateError] = useState<string | null>(null);
|
||||
const [cloudSessionsEnabled, setCloudSessionsEnabled] = useState(false);
|
||||
const [cloudSessionsLoading, setCloudSessionsLoading] = useState(true);
|
||||
const [cloudSessionsSaving, setCloudSessionsSaving] = useState(false);
|
||||
const [cloudSessionsError, setCloudSessionsError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
// The environment override can differ from the stored opt-in.
|
||||
const [cloudSessionsEffective, setCloudSessionsEffective] = useState<
|
||||
boolean | null
|
||||
>(null);
|
||||
// Keep the preview hidden until the rollout service explicitly enables it.
|
||||
const [cloudSessionsAvailable, setCloudSessionsAvailable] = useState(false);
|
||||
|
||||
const refreshCloudSessionsEffective = useCallback(async () => {
|
||||
try {
|
||||
const flags = await desktopClient.invoke<{
|
||||
cloudAgents?: boolean;
|
||||
cloudAgentsAvailable?: boolean;
|
||||
}>("get_feature_flags");
|
||||
setCloudSessionsEffective(Boolean(flags.cloudAgents));
|
||||
setCloudSessionsAvailable(flags.cloudAgentsAvailable === true);
|
||||
} catch {
|
||||
setCloudSessionsEffective(null);
|
||||
setCloudSessionsAvailable(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => setAppIconLocation(appIconSurface(navigator.userAgent)), []);
|
||||
useEffect(() => subscribeToAppFontSize(setFontSize), []);
|
||||
@@ -734,45 +708,21 @@ function GeneralSettingsContent({
|
||||
setTelemetryError(null);
|
||||
setAutoUpdateLoading(true);
|
||||
setAutoUpdateError(null);
|
||||
setCloudSessionsLoading(true);
|
||||
setCloudSessionsError(null);
|
||||
await Promise.all([
|
||||
(async () => {
|
||||
try {
|
||||
const settings = await desktopClient.invoke<GlobalSettingsResponse>(
|
||||
"get_global_settings",
|
||||
);
|
||||
setTelemetryOptOut(settings.telemetryOptOut);
|
||||
setAutoUpdateEnabled(settings.autoUpdateEnabled);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
setTelemetryError(message);
|
||||
setAutoUpdateError(message);
|
||||
} finally {
|
||||
setTelemetryLoading(false);
|
||||
setAutoUpdateLoading(false);
|
||||
}
|
||||
})(),
|
||||
(async () => {
|
||||
try {
|
||||
const desktopSettings = await desktopClient.invoke<{
|
||||
cloudSessionsEnabled: boolean;
|
||||
}>("get_desktop_settings");
|
||||
setCloudSessionsEnabled(
|
||||
Boolean(desktopSettings.cloudSessionsEnabled),
|
||||
);
|
||||
} catch (error) {
|
||||
setCloudSessionsError(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
} finally {
|
||||
setCloudSessionsLoading(false);
|
||||
}
|
||||
})(),
|
||||
refreshCloudSessionsEffective(),
|
||||
]);
|
||||
}, [refreshCloudSessionsEffective]);
|
||||
try {
|
||||
const settings = await desktopClient.invoke<GlobalSettingsResponse>(
|
||||
"get_global_settings",
|
||||
);
|
||||
setTelemetryOptOut(settings.telemetryOptOut);
|
||||
setAutoUpdateEnabled(settings.autoUpdateEnabled);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setTelemetryError(message);
|
||||
setAutoUpdateError(message);
|
||||
} finally {
|
||||
setTelemetryLoading(false);
|
||||
setAutoUpdateLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
@@ -821,26 +771,6 @@ function GeneralSettingsContent({
|
||||
}
|
||||
};
|
||||
|
||||
const updateCloudSessionsEnabled = async (nextValue: boolean) => {
|
||||
const previousValue = cloudSessionsEnabled;
|
||||
setCloudSessionsEnabled(nextValue);
|
||||
setCloudSessionsSaving(true);
|
||||
setCloudSessionsError(null);
|
||||
try {
|
||||
const settings = await desktopClient.invoke<{
|
||||
cloudSessionsEnabled: boolean;
|
||||
}>("set_cloud_sessions_enabled", { cloud_sessions_enabled: nextValue });
|
||||
setCloudSessionsEnabled(Boolean(settings.cloudSessionsEnabled));
|
||||
await refreshCloudSessionsEffective();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setCloudSessionsEnabled(previousValue);
|
||||
setCloudSessionsError(message);
|
||||
} finally {
|
||||
setCloudSessionsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateTheme = (darkModeEnabled: boolean) => {
|
||||
const nextTheme = darkModeEnabled ? "dark" : "light";
|
||||
setTheme(setStoredHubTheme(nextTheme));
|
||||
@@ -1051,46 +981,6 @@ function GeneralSettingsContent({
|
||||
onCheckedChange={(checked) => void updateAutoUpdateEnabled(checked)}
|
||||
/>
|
||||
</div>
|
||||
{cloudSessionsAvailable ? (
|
||||
<div className="flex py-4 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="flex items-center gap-2 text-base font-semibold text-foreground">
|
||||
Cloud sessions
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium uppercase tracking-wide text-primary">
|
||||
Preview
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Run Cline on your GitHub repositories in secure cloud sandboxes.
|
||||
Adds a Cloud option to the new-session composer. Requires a
|
||||
Cline account with GitHub connected.
|
||||
</p>
|
||||
{cloudSessionsError ? (
|
||||
<p className="mt-2 text-xs text-destructive" role="alert">
|
||||
Failed to update cloud sessions setting: {cloudSessionsError}
|
||||
</p>
|
||||
) : null}
|
||||
{cloudSessionsEffective !== null &&
|
||||
!cloudSessionsLoading &&
|
||||
cloudSessionsEffective !== cloudSessionsEnabled ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Cloud sessions are currently{" "}
|
||||
{cloudSessionsEffective ? "enabled" : "disabled"} by the
|
||||
CLINE_CODE_CLOUD_AGENTS environment override, which takes
|
||||
precedence over this setting.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Switch
|
||||
aria-label="Cloud sessions"
|
||||
checked={cloudSessionsEnabled}
|
||||
disabled={cloudSessionsLoading || cloudSessionsSaving}
|
||||
onCheckedChange={(checked) =>
|
||||
void updateCloudSessionsEnabled(checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex py-4 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-base font-semibold text-foreground">Telemetry</p>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||
import fs from "node:fs"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { createStorageContext, type StorageContext } from "@/shared/storage/storage-context"
|
||||
import { migrateOpenAiCompatibleBaseUrlToV1 } from "../openai-base-url-migration"
|
||||
|
||||
describe("migrateOpenAiCompatibleBaseUrlToV1", () => {
|
||||
let tempDir: string
|
||||
let storage: StorageContext
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openai-base-url-migration-"))
|
||||
storage = createStorageContext({ clineDir: tempDir, workspacePath: tempDir })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
for (const [baseUrl, expected] of [
|
||||
["https://example.com", "https://example.com/v1"],
|
||||
["http://localhost:8000/", "http://localhost:8000/v1"],
|
||||
]) {
|
||||
it(`adds /v1 to the host-only base URL ${baseUrl}`, () => {
|
||||
storage.globalState.update("openAiBaseUrl", baseUrl)
|
||||
|
||||
migrateOpenAiCompatibleBaseUrlToV1(storage)
|
||||
|
||||
expect(storage.globalState.get<string>("openAiBaseUrl")).toBe(expected)
|
||||
})
|
||||
}
|
||||
|
||||
for (const baseUrl of ["https://example.com/v1", "https://example.com/api/foo", "not a url"]) {
|
||||
it(`leaves the base URL ${baseUrl} unchanged`, () => {
|
||||
storage.globalState.update("openAiBaseUrl", baseUrl)
|
||||
|
||||
migrateOpenAiCompatibleBaseUrlToV1(storage)
|
||||
|
||||
expect(storage.globalState.get<string>("openAiBaseUrl")).toBe(baseUrl)
|
||||
})
|
||||
}
|
||||
|
||||
it("runs only once, so a host-only URL saved afterwards stays as entered", () => {
|
||||
storage.globalState.update("openAiBaseUrl", "https://example.com")
|
||||
migrateOpenAiCompatibleBaseUrlToV1(storage)
|
||||
storage.globalState.update("openAiBaseUrl", "https://example.com")
|
||||
|
||||
migrateOpenAiCompatibleBaseUrlToV1(storage)
|
||||
|
||||
expect(storage.globalState.get<string>("openAiBaseUrl")).toBe("https://example.com")
|
||||
})
|
||||
|
||||
it("marks itself done when there is no base URL, so a later host-only URL stays as entered", () => {
|
||||
migrateOpenAiCompatibleBaseUrlToV1(storage)
|
||||
storage.globalState.update("openAiBaseUrl", "https://example.com")
|
||||
|
||||
migrateOpenAiCompatibleBaseUrlToV1(storage)
|
||||
|
||||
expect(storage.globalState.get<string>("openAiBaseUrl")).toBe("https://example.com")
|
||||
})
|
||||
|
||||
it("shares its marker across hosts through globalState.json", () => {
|
||||
storage.globalState.update("openAiBaseUrl", "https://example.com")
|
||||
migrateOpenAiCompatibleBaseUrlToV1(storage)
|
||||
storage.globalState.update("openAiBaseUrl", "https://example.com")
|
||||
|
||||
const otherHost = createStorageContext({ clineDir: tempDir, workspacePath: tempDir })
|
||||
migrateOpenAiCompatibleBaseUrlToV1(otherHost)
|
||||
|
||||
expect(otherHost.globalState.get<string>("openAiBaseUrl")).toBe("https://example.com")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { StorageContext } from "@/shared/storage/storage-context"
|
||||
|
||||
const MIGRATED_KEY = "__openAiBaseUrlV1Migrated"
|
||||
|
||||
/**
|
||||
* Earlier SDK builds sent requests for an OpenAI Compatible base URL with no
|
||||
* path (`http://localhost:8000`) to `<host>/v1`; the URL is now used as entered.
|
||||
* Save the `/v1` so existing setups keep reaching the same endpoint.
|
||||
*
|
||||
* Runs once across all hosts: the marker lives in the shared globalState.json
|
||||
* and is set even when there is nothing to rewrite, so a host-only URL saved
|
||||
* afterwards (in any host) stays as entered.
|
||||
*/
|
||||
export function migrateOpenAiCompatibleBaseUrlToV1(storage: StorageContext): void {
|
||||
if (storage.globalState.get(MIGRATED_KEY)) {
|
||||
return
|
||||
}
|
||||
const baseUrl = storage.globalState.get<string>("openAiBaseUrl")?.trim()
|
||||
if (baseUrl && URL.canParse(baseUrl)) {
|
||||
const url = new URL(baseUrl)
|
||||
if (url.pathname === "/") {
|
||||
url.pathname = "/v1"
|
||||
storage.globalState.update("openAiBaseUrl", url.toString().replace(/\/$/, ""))
|
||||
Logger.info("[Migration] Added /v1 to the host-only OpenAI Compatible base URL")
|
||||
}
|
||||
}
|
||||
storage.globalState.update(MIGRATED_KEY, true)
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { improveWithCline } from "./core/controller/commands/improveWithCline"
|
||||
import { sendAddToInputEvent } from "./core/controller/ui/subscribeToAddToInput"
|
||||
import { sendShowWebviewEvent } from "./core/controller/ui/subscribeToShowWebview"
|
||||
import { HookDiscoveryCache } from "./core/hooks/HookDiscoveryCache"
|
||||
import { migrateOpenAiCompatibleBaseUrlToV1 } from "./core/storage/openai-base-url-migration"
|
||||
import {
|
||||
cleanupMcpMarketplaceCatalogFromGlobalState,
|
||||
cleanupOldApiKey,
|
||||
@@ -84,6 +85,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
const storageContext = createStorageContext({ workspacePath })
|
||||
await exportVSCodeStorageToSharedFiles(context, storageContext)
|
||||
migrateOpenAiCompatibleBaseUrlToV1(storageContext)
|
||||
|
||||
// 4. Register services and perform common initialization
|
||||
// IMPORTANT: Must be done after host provider is setup and migrations are complete
|
||||
|
||||
@@ -14,6 +14,7 @@ import { retryOperation } from "@utils/retry"
|
||||
import * as path from "path"
|
||||
import { initialize, tearDown } from "@/common"
|
||||
import { SqliteLockManager } from "@/core/locks/SqliteLockManager"
|
||||
import { migrateOpenAiCompatibleBaseUrlToV1 } from "@/core/storage/openai-base-url-migration"
|
||||
import { WebviewProvider } from "@/core/webview"
|
||||
import { AuthHandler } from "@/hosts/external/AuthHandler"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
@@ -87,6 +88,7 @@ async function main() {
|
||||
|
||||
// Create shared file-backed storage
|
||||
const storageContext = createStorageContext()
|
||||
migrateOpenAiCompatibleBaseUrlToV1(storageContext)
|
||||
const webviewProvider = await initialize(storageContext)
|
||||
|
||||
// Enable the localhost HTTP server that handles auth redirects.
|
||||
|
||||
+1
-1
@@ -318,7 +318,7 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
|
||||
}, [bannerConfig, banners, clineUser, handleBannerAction, handleBannerDismiss, clinePassPromoBanner])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 w-full h-full p-0 m-0">
|
||||
<div className="flex flex-col flex-1 w-full min-h-0 p-0 m-0">
|
||||
<WhatsNewModal
|
||||
onBannerAction={handleBannerAction}
|
||||
onClose={handleCloseWhatsNewModal}
|
||||
|
||||
@@ -39,14 +39,6 @@ const agentFeatures: FeatureToggle[] = [
|
||||
stateKey: "useAutoCondense",
|
||||
settingKey: "useAutoCondense",
|
||||
},
|
||||
{
|
||||
id: "cloud-sessions",
|
||||
label: "Cloud",
|
||||
description:
|
||||
"Run Cline on your GitHub repositories in secure cloud sandboxes. Adds a Cloud option to the new task composer. Requires a Cline account with GitHub connected.",
|
||||
stateKey: "cloudSessionsEnabled",
|
||||
settingKey: "cloudSessionsEnabled",
|
||||
},
|
||||
]
|
||||
|
||||
const editorFeatures: FeatureToggle[] = [
|
||||
@@ -175,13 +167,11 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP
|
||||
subagentsEnabled,
|
||||
worktreesEnabled: worktreesEnabled?.user,
|
||||
backgroundEditEnabled,
|
||||
cloudSessionsEnabled,
|
||||
}
|
||||
|
||||
// Visibility lookup for features with feature flags
|
||||
const featureVisibility: Record<string, boolean | undefined> = {
|
||||
worktreesEnabled: worktreesEnabled?.featureFlag,
|
||||
cloudSessionsEnabled: !!cloudSessionsAvailable,
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -228,6 +218,14 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP
|
||||
label="Web Search"
|
||||
onChange={(checked) => updateSetting("webSearchEnabled", checked)}
|
||||
/>
|
||||
<FeatureRow
|
||||
checked={cloudSessionsEnabled}
|
||||
description="Run Cline on your GitHub repositories in secure cloud sandboxes. Adds a Cloud option to the new task composer. Requires a Cline account with GitHub connected."
|
||||
id="cloud-sessions"
|
||||
isVisible={!!cloudSessionsAvailable}
|
||||
label="Cloud"
|
||||
onChange={(checked) => updateSetting("cloudSessionsEnabled", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -60,6 +60,11 @@ import {
|
||||
recordProviderRequestCapture,
|
||||
wrapFetchForProviderRequestCapture,
|
||||
} from "./provider-request-capture";
|
||||
import {
|
||||
appendRequestId,
|
||||
extractResponseRequestId,
|
||||
findRequestIdHeader,
|
||||
} from "./response-request-id";
|
||||
import {
|
||||
applyPromptCacheToLastTextPart,
|
||||
shouldApplyPromptCache,
|
||||
@@ -1467,13 +1472,19 @@ interface CapturedStreamError {
|
||||
* does not report the same failure a second time.
|
||||
*/
|
||||
reported?: boolean;
|
||||
requestId?: string;
|
||||
baseMessage: string;
|
||||
}
|
||||
|
||||
function captureStreamError(error: unknown): CapturedStreamError {
|
||||
const requestId = extractResponseRequestId(error);
|
||||
const baseMessage = extractErrorMessage(error);
|
||||
return {
|
||||
message: extractErrorMessage(error),
|
||||
message: appendRequestId(baseMessage, requestId),
|
||||
baseMessage,
|
||||
errorClass: classifyProviderError(error),
|
||||
retryable: isRetryableBeyondSdkRetries(error),
|
||||
...(requestId ? { requestId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1517,9 +1528,7 @@ async function* emitAiSdkEvents(
|
||||
continue;
|
||||
}
|
||||
if (part.type === "finish-step") {
|
||||
requestId = Object.entries(part.response?.headers ?? {}).find(
|
||||
([name]) => name.toLowerCase() === "x-request-id",
|
||||
)?.[1];
|
||||
requestId = findRequestIdHeader(part.response?.headers);
|
||||
continue;
|
||||
}
|
||||
if (part.type === "text-delta") {
|
||||
@@ -1994,10 +2003,11 @@ async function* emitAiSdkEvents(
|
||||
};
|
||||
}
|
||||
|
||||
const finishRequestId = requestId ?? streamError?.requestId;
|
||||
yield {
|
||||
type: "finish",
|
||||
reason: streamError ? "error" : mapFinishReason(finishReason),
|
||||
...(requestId ? { requestId } : {}),
|
||||
...(finishRequestId ? { requestId: finishRequestId } : {}),
|
||||
error: streamError?.message,
|
||||
errorClass: streamError?.errorClass,
|
||||
errorRetryable: streamError?.retryable,
|
||||
@@ -2381,13 +2391,14 @@ function createAiSdkProvider(
|
||||
component: "llms",
|
||||
operation: "provider.stream",
|
||||
error: streamError,
|
||||
errorMessage: msg,
|
||||
errorMessage: captured.baseMessage,
|
||||
severity: "error",
|
||||
handled: true,
|
||||
context: {
|
||||
providerId: request.providerId,
|
||||
modelId: request.modelId,
|
||||
providerKind: kind,
|
||||
requestId: captured.requestId,
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -2430,13 +2441,14 @@ function createAiSdkProvider(
|
||||
component: "llms",
|
||||
operation: "provider.create_or_stream",
|
||||
error,
|
||||
errorMessage: msg,
|
||||
errorMessage: captured.baseMessage,
|
||||
severity: "error",
|
||||
handled: true,
|
||||
context: {
|
||||
providerId: request.providerId,
|
||||
modelId: request.modelId,
|
||||
providerKind: kind,
|
||||
requestId: captured.requestId,
|
||||
},
|
||||
});
|
||||
yield {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type ITelemetryService,
|
||||
resetSdkErrorRateLimiterForTests,
|
||||
} from "@cline/shared";
|
||||
import { APICallError } from "ai";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { normalizeModelsDevProviderModels } from "../catalog/catalog-live";
|
||||
import { createOpenAICompatibleProvider } from "./ai-sdk";
|
||||
@@ -96,22 +97,28 @@ function createFetchMock() {
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("ai", () => ({
|
||||
jsonSchema: (schema: unknown, options: unknown) => ({
|
||||
jsonSchema: schema,
|
||||
...(options && typeof options === "object" ? options : {}),
|
||||
}),
|
||||
generateImage: (input: unknown) => generateImageSpy(input),
|
||||
streamText: (input: unknown) => streamTextSpy(input),
|
||||
// `wrapLanguageModel` is used by the openai-compatible and mistral
|
||||
// vendors to attach `splitToolImagesMiddleware`. The middleware itself
|
||||
// is exercised by its own unit tests; here we just need an identity
|
||||
// pass-through so the vendor factories' downstream `model:` callbacks
|
||||
// keep returning the spy-produced mock objects unchanged. (The mock
|
||||
// objects don't satisfy the real `LanguageModelV4` interface, so we
|
||||
// can't call the real `wrapLanguageModel` either way.)
|
||||
wrapLanguageModel: ({ model }: { model: unknown }) => model,
|
||||
}));
|
||||
vi.mock("ai", async (importOriginal) => {
|
||||
const { APICallError, RetryError } =
|
||||
await importOriginal<typeof import("ai")>();
|
||||
return {
|
||||
APICallError,
|
||||
RetryError,
|
||||
jsonSchema: (schema: unknown, options: unknown) => ({
|
||||
jsonSchema: schema,
|
||||
...(options && typeof options === "object" ? options : {}),
|
||||
}),
|
||||
generateImage: (input: unknown) => generateImageSpy(input),
|
||||
streamText: (input: unknown) => streamTextSpy(input),
|
||||
// `wrapLanguageModel` is used by the openai-compatible and mistral
|
||||
// vendors to attach `splitToolImagesMiddleware`. The middleware itself
|
||||
// is exercised by its own unit tests; here we just need an identity
|
||||
// pass-through so the vendor factories' downstream `model:` callbacks
|
||||
// keep returning the spy-produced mock objects unchanged. (The mock
|
||||
// objects don't satisfy the real `LanguageModelV4` interface, so we
|
||||
// can't call the real `wrapLanguageModel` either way.)
|
||||
wrapLanguageModel: ({ model }: { model: unknown }) => model,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@ai-sdk/openai", () => ({
|
||||
createOpenAI: () => ({
|
||||
@@ -1401,6 +1408,44 @@ describe("sdk-gateway", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("includes the response x-request-id in stream error messages", async () => {
|
||||
streamTextSpy.mockReturnValue({
|
||||
fullStream: makeStreamParts([
|
||||
{
|
||||
type: "error",
|
||||
error: new APICallError({
|
||||
message: "Unauthorized",
|
||||
url: "https://api.cline.bot/api/v1/chat/completions",
|
||||
requestBodyValues: {},
|
||||
statusCode: 401,
|
||||
responseHeaders: { "X-Request-ID": "req_abc123" },
|
||||
responseBody: JSON.stringify({
|
||||
error: { message: "Unauthorized: re-authenticate" },
|
||||
}),
|
||||
}),
|
||||
},
|
||||
]),
|
||||
});
|
||||
const gateway = createGateway({
|
||||
providerConfigs: [{ providerId: "openai-native", apiKey: "test" }],
|
||||
});
|
||||
|
||||
const events = await collect(
|
||||
await gateway.stream({
|
||||
providerId: "openai-native",
|
||||
modelId: "gpt-5-mini",
|
||||
messages: baseMessages,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
type: "finish",
|
||||
reason: "error",
|
||||
requestId: "req_abc123",
|
||||
error: "Unauthorized: re-authenticate (Request ID: req_abc123)",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves valid mixed text when a generated image is rejected", async () => {
|
||||
const oversized = "A".repeat(DEFAULT_MAX_IMAGE_ENCODED_BYTES + 4);
|
||||
streamTextSpy.mockReturnValue({
|
||||
@@ -2802,6 +2847,47 @@ describe("sdk-gateway", () => {
|
||||
expect(sdkErrors).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("rate-limits stream failures that differ only by request ID", async () => {
|
||||
const { telemetry, capture } = createTelemetryMock();
|
||||
const gateway = createGateway({
|
||||
telemetry,
|
||||
providerConfigs: [{ providerId: "openai-native", apiKey: "test" }],
|
||||
});
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const rawError = new APICallError({
|
||||
message: "Unauthorized",
|
||||
url: "https://api.cline.bot/api/v1/chat/completions",
|
||||
requestBodyValues: {},
|
||||
statusCode: 401,
|
||||
responseHeaders: { "x-request-id": `req_${"abcdefghijklmnopqrst"[i]}` },
|
||||
});
|
||||
streamTextSpy.mockImplementation(
|
||||
(input: { onError?: (event: { error: unknown }) => void }) => {
|
||||
input.onError?.({ error: rawError });
|
||||
return {
|
||||
fullStream: makeStreamParts([{ type: "error", error: rawError }]),
|
||||
};
|
||||
},
|
||||
);
|
||||
await collect(
|
||||
await gateway.stream({
|
||||
providerId: "openai-native",
|
||||
modelId: "gpt-5-mini",
|
||||
messages: baseMessages,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const sdkErrors = capture.mock.calls.filter(
|
||||
([call]) => (call as { event: string }).event === "sdk.error",
|
||||
);
|
||||
expect(sdkErrors).toHaveLength(5);
|
||||
expect(sdkErrors[0]?.[0]).toMatchObject({
|
||||
properties: { error_message: "Unauthorized", requestId: "req_a" },
|
||||
});
|
||||
});
|
||||
|
||||
it("records the extracted cause when provider creation fails through a generic wrapper", async () => {
|
||||
streamTextSpy.mockImplementation(() => {
|
||||
throw new Error("No output generated. Check the stream for errors.", {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { APICallError, RetryError } from "ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
extractResponseRequestId,
|
||||
findRequestIdHeader,
|
||||
} from "./response-request-id";
|
||||
|
||||
function apiCallError(responseHeaders?: Record<string, string>) {
|
||||
return new APICallError({
|
||||
message: "Bad Gateway",
|
||||
url: "https://api.cline.bot/api/v1/chat/completions",
|
||||
requestBodyValues: {},
|
||||
statusCode: 502,
|
||||
responseHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
describe("findRequestIdHeader", () => {
|
||||
it("matches the header case-insensitively and trims it", () => {
|
||||
expect(findRequestIdHeader({ "X-Request-ID": " req_1 " })).toBe("req_1");
|
||||
});
|
||||
|
||||
it("returns undefined for missing or blank headers", () => {
|
||||
expect(findRequestIdHeader(undefined)).toBeUndefined();
|
||||
expect(findRequestIdHeader({ "x-request-id": " " })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractResponseRequestId", () => {
|
||||
it("reads a top-level APICallError", () => {
|
||||
expect(
|
||||
extractResponseRequestId(apiCallError({ "x-request-id": "req_top" })),
|
||||
).toBe("req_top");
|
||||
});
|
||||
|
||||
it("walks the cause chain to a wrapped APICallError", () => {
|
||||
const wrapped = new Error("outer", {
|
||||
cause: new Error("middle", {
|
||||
cause: apiCallError({ "x-request-id": "req_nested" }),
|
||||
}),
|
||||
});
|
||||
expect(extractResponseRequestId(wrapped)).toBe("req_nested");
|
||||
});
|
||||
|
||||
it("stops at the depth guard on cyclic causes", () => {
|
||||
const a = new Error("a") as Error & { cause?: unknown };
|
||||
const b = new Error("b", { cause: a });
|
||||
a.cause = b;
|
||||
expect(extractResponseRequestId(a)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to earlier RetryError attempts newest-first", () => {
|
||||
const error = new RetryError({
|
||||
message: "Failed after 3 attempts",
|
||||
reason: "maxRetriesExceeded",
|
||||
errors: [
|
||||
apiCallError({ "x-request-id": "req_first" }),
|
||||
apiCallError({ "x-request-id": "req_second" }),
|
||||
apiCallError(),
|
||||
],
|
||||
});
|
||||
expect(extractResponseRequestId(error)).toBe("req_second");
|
||||
});
|
||||
|
||||
it("prefers RetryError.lastError when it has a request id", () => {
|
||||
const error = new RetryError({
|
||||
message: "Failed after 2 attempts",
|
||||
reason: "maxRetriesExceeded",
|
||||
errors: [
|
||||
apiCallError({ "x-request-id": "req_first" }),
|
||||
apiCallError({ "x-request-id": "req_last" }),
|
||||
],
|
||||
});
|
||||
expect(extractResponseRequestId(error)).toBe("req_last");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { APICallError, RetryError } from "ai";
|
||||
|
||||
const REQUEST_ID_HEADER = "x-request-id";
|
||||
const MAX_CAUSE_DEPTH = 5;
|
||||
|
||||
export function findRequestIdHeader(
|
||||
headers: Record<string, string | undefined> | undefined,
|
||||
): string | undefined {
|
||||
const value = Object.entries(headers ?? {}).find(
|
||||
([name]) => name.toLowerCase() === REQUEST_ID_HEADER,
|
||||
)?.[1];
|
||||
return value?.trim() || undefined;
|
||||
}
|
||||
|
||||
export function extractResponseRequestId(
|
||||
error: unknown,
|
||||
depth = 0,
|
||||
): string | undefined {
|
||||
if (depth > MAX_CAUSE_DEPTH || !error || typeof error !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
if (RetryError.isInstance(error)) {
|
||||
const fromLast = extractResponseRequestId(error.lastError, depth + 1);
|
||||
if (fromLast) {
|
||||
return fromLast;
|
||||
}
|
||||
for (let i = error.errors.length - 1; i >= 0; i--) {
|
||||
const attempt = error.errors[i];
|
||||
if (attempt === error.lastError) {
|
||||
continue;
|
||||
}
|
||||
const fromAttempt = extractResponseRequestId(attempt, depth + 1);
|
||||
if (fromAttempt) {
|
||||
return fromAttempt;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (APICallError.isInstance(error)) {
|
||||
const fromHeaders = findRequestIdHeader(error.responseHeaders);
|
||||
if (fromHeaders) {
|
||||
return fromHeaders;
|
||||
}
|
||||
}
|
||||
return extractResponseRequestId(
|
||||
(error as { cause?: unknown }).cause,
|
||||
depth + 1,
|
||||
);
|
||||
}
|
||||
|
||||
export function appendRequestId(
|
||||
message: string,
|
||||
requestId: string | undefined,
|
||||
): string {
|
||||
if (!requestId || message.includes(requestId)) {
|
||||
return message;
|
||||
}
|
||||
return `${message} (Request ID: ${requestId})`;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
export const FeatureFlag = {
|
||||
/** Enables ClinePass provider/model list exposure in supported clients. */
|
||||
CLINE_PASS: "ext-cline-pass",
|
||||
/** Makes the Cloud sessions opt-in visible in the Cline Code desktop app. */
|
||||
/** Enables Cloud sessions in the Cline Code desktop app. */
|
||||
CODE_CLOUD_AGENTS: "code-cloud-agents",
|
||||
/** Shows the GitHub integration step in the desktop app */
|
||||
CODE_ONBOARDING_GITHUB: "code-onboarding-github",
|
||||
|
||||
Reference in New Issue
Block a user