refactor(desktop): move provider and model management to hub

This commit is contained in:
abeatrix
2026-10-23 18:53:02 -07:00
parent 4069b85c0a
commit 6ea630ac7e
19 changed files with 757 additions and 176 deletions
@@ -123,12 +123,13 @@ the response to the sandbox Hub. Desktop owns no cloud API client or sandbox con
Disposing its cloud viewer only unsubscribes; account-scope retirement and shutdown
are owned by the daemon.
### 3. Provider Management — Direct ProviderSettingsManager
### 3. Provider Management — Shared Hub Commands
```typescript
import { ProviderSettingsManager, listLocalProviders, ... } from "@cline/core";
const manager = new ProviderSettingsManager();
```
Provider catalog/model discovery, recommended models, provider settings, custom
provider creation, and model overrides use `createHubProvidersApi` over the
existing local Hub connection. Credential mutations and sign-out cleanup belong
to the daemon. Desktop retains cloud-view and account-context reactions.
Provider OAuth and voice operations still use their existing sidecar services.
### 4. Session Storage — Direct SqliteSessionStore
@@ -168,11 +169,11 @@ OpenCode); concurrent requests share one fetch and reuse its ten-minute cache.
Endpoint-owned lists such as Baseten, Hicap, Poolside, LiteLLM, Ollama, and LM Studio use their existing
discovery endpoints instead. Catalog and public endpoint requests time out after
five seconds, and the initial picker remains usable while a refresh is pending.
The sidecar omits bundled `knownModels` from the discovery config so they cannot
The Hub omits bundled `knownModels` from the discovery config so they cannot
override live metadata; explicitly registered model overrides retain precedence.
The catalog also supplies `ProviderAuthInfo` (capabilities and optional local CLI
command/docs) through the shared RPC contract. The sidecar resolves these facts
command/docs) through the shared RPC contract. The Hub resolves these facts
from the registered provider catalog; chat validation and credential-error UI use
that data rather than importing the browser LLM registry. Error-message metadata
retains the provider facts used for that message, so changing providers does not
@@ -203,14 +204,16 @@ Supported commands:
| Command | Implementation |
|---------|---------------|
| `chat_session_command` | shared Hub through `ClineCore`; cloud sessions use `HubCloudSessionClient` through `CloudSessionManager` |
| `list_provider_catalog` | `ProviderSettingsManager` + `listLocalProviders` |
| `list_provider_models` | `getLocalProviderModels` |
| `list_provider_catalog` | shared Hub `providers.list` |
| `list_provider_models` | shared Hub `providers.models.list` |
| `list_cline_recommended_models` | shared Hub `providers.models.recommended` |
| `update_provider_models` | shared Hub `providers.models.update` |
| `list_transcription_models` | `getLocalTranscriptionModels` |
| `save_voice_input_settings` | validates and persists the selected transcription provider/model |
| `create_streaming_transcription_session` | mints a short-lived, transcription-bound browser token without exposing provider credentials |
| `transcribe_audio` | configured voice input selection + provider credentials |
| `save_provider_settings` | `saveLocalProviderSettings` |
| `add_provider` | `addLocalProvider` |
| `save_provider_settings` | shared Hub `providers.settings.save` |
| `add_provider` | shared Hub `providers.add` |
| `run_provider_oauth_login` | `loginLocalProvider` |
| `list_chat_sessions` | `SqliteSessionStore` + file discovery, merged with cloud sessions |
| `list_discovered_sessions` | Merged discovery (local + cloud) |
@@ -12,7 +12,6 @@ const getProviderSettingsMock = vi.hoisted(() => vi.fn());
const saveProviderSettingsMock = vi.hoisted(() => vi.fn());
const persistProviderSettingsMock = vi.hoisted(() => vi.fn());
const resolveProviderApiKeyMock = vi.hoisted(() => vi.fn());
const clearLegacyProviderCredentialsMock = vi.hoisted(() => vi.fn());
const runProviderOAuthLoginMock = vi.hoisted(() => vi.fn());
let testDataDir: string;
@@ -21,8 +20,9 @@ vi.mock("./oauth-login", () => ({
cancelProviderOAuthLogin: vi.fn(),
}));
vi.mock("./legacy-provider-credentials", () => ({
clearLegacyProviderCredentials: clearLegacyProviderCredentialsMock,
vi.mock("./context", async () => ({
...(await vi.importActual<typeof import("./context")>("./context")),
ensureSharedHubClient: vi.fn().mockResolvedValue({}),
}));
vi.mock("@cline/core", async () => {
@@ -40,7 +40,7 @@ vi.mock("@cline/core", async () => {
getProviderSettings = getProviderSettingsMock;
saveProviderSettings = persistProviderSettingsMock;
},
saveLocalProviderSettings: saveProviderSettingsMock,
createHubProvidersApi: () => ({ saveSettings: saveProviderSettingsMock }),
RuntimeOAuthTokenManager: class {
resolveProviderApiKey = resolveProviderApiKeyMock;
},
@@ -86,7 +86,6 @@ beforeEach(() => {
saveProviderSettingsMock.mockReset();
persistProviderSettingsMock.mockReset();
resolveProviderApiKeyMock.mockReset();
clearLegacyProviderCredentialsMock.mockReset();
runProviderOAuthLoginMock
.mockReset()
.mockResolvedValue({ accessToken: "token" });
@@ -148,7 +147,11 @@ describe("provider settings cloud session lifecycle", () => {
...update,
auth: { accessToken: "token", accountId: "acct-1" },
});
return { providerId: "cline", enabled: true };
return {
providerId: "cline",
enabled: true,
clineCredentialsChanged: false,
};
});
const { handleCommand } = await import("./commands");
await handleCommand(ctx, "save_provider_settings", {
@@ -186,7 +189,11 @@ describe("provider settings cloud session lifecycle", () => {
},
},
);
return { providerId: "cline", enabled: update.enabled !== false };
return {
providerId: "cline",
enabled: update.enabled !== false,
clineCredentialsChanged: true,
};
});
const { handleCommand } = await import("./commands");
await handleCommand(ctx, "save_provider_settings", {
@@ -571,40 +578,28 @@ describe("cline_account keeps feature-flag identity in sync", () => {
);
});
it("signs out of the shared cline entry and legacy secrets when cline-pass is disabled", async () => {
it("forwards Cline Pass sign-out to the hub and updates the cloud view", async () => {
const { ctx } = createContext();
const dispose = vi.fn().mockResolvedValue(undefined);
ctx.cloudSessionManager = { dispose } as unknown as NonNullable<
SidecarContext["cloudSessionManager"]
>;
getProviderSettingsMock.mockReturnValue({
auth: { accessToken: "token", accountId: "acct-1" },
getProviderSettingsMock.mockReturnValue(undefined);
saveProviderSettingsMock.mockResolvedValue({
providerId: "cline-pass",
enabled: false,
settingsPath: "/tmp/settings.json",
clineCredentialsChanged: true,
});
saveProviderSettingsMock.mockImplementation(
(_manager: unknown, request: { providerId: string }) => {
if (request.providerId === "cline") {
getProviderSettingsMock.mockReturnValue(undefined);
}
return {
providerId: request.providerId,
enabled: false,
settingsPath: "/tmp/settings.json",
};
},
);
const { handleCommand } = await import("./commands");
await handleCommand(ctx, "save_provider_settings", {
provider: "cline-pass",
enabled: false,
});
// Cline Pass stores its credentials under "cline", so both entries go,
// and the legacy secrets are cleared for the storage provider.
expect(saveProviderSettingsMock.mock.calls.map(([, r]) => r)).toEqual([
expect(saveProviderSettingsMock).toHaveBeenCalledOnce();
expect(saveProviderSettingsMock).toHaveBeenCalledWith(
expect.objectContaining({ providerId: "cline-pass", enabled: false }),
{ providerId: "cline", enabled: false },
]);
expect(clearLegacyProviderCredentialsMock).toHaveBeenCalledWith("cline");
);
expect(dispose).toHaveBeenCalledOnce();
expect(ctx.cloudSessionManager).toBeNull();
});
@@ -171,42 +171,37 @@ describe("desktop settings commands", () => {
expect(ctx.liveSessions.has("cloud")).toBe(true);
});
it("loads cloud-only models only for an enabled cloud picker", async () => {
it("routes model discovery through the local hub and applies the desktop cloud gate", async () => {
const { ctx } = createContext();
const fetchMock = vi.fn(async (input: string | URL | Request) => {
if (String(input) === "https://models.dev/api.json") {
return new Response(JSON.stringify({}), { status: 200 });
}
return new Response(
JSON.stringify({
clineCloud: [
{
id: "cline-cloud/cloud-only",
name: "Cloud Only",
},
],
}),
{ status: 200 },
);
const command = vi.fn().mockResolvedValue({
ok: true,
payload: { providerId: "cline", models: [] },
});
vi.stubGlobal("fetch", fetchMock);
ctx.runtimeBindings.set(LOCAL_ENVIRONMENT_ID, {
hubClient: { command },
} as unknown as SessionRuntimeBinding);
ctx.activeEnvironmentId = "ssh-remote";
process.env.CLINE_CODE_CLOUD_AGENTS = "0";
const local = (await handleCommand(ctx, "list_provider_models", {
await handleCommand(ctx, "list_provider_models", {
provider: "cline",
includeCloudModels: true,
})) as { models: Array<{ id: string }> };
expect(
local.models.some((model) => model.id.startsWith("cline-cloud/")),
).toBe(false);
});
expect(command).toHaveBeenLastCalledWith(
"providers.models.list",
{ providerId: "cline", includeCloudModels: false },
undefined,
{ retryAfterDispatch: false },
);
process.env.CLINE_CODE_CLOUD_AGENTS = "1";
const cloud = (await handleCommand(ctx, "list_provider_models", {
await handleCommand(ctx, "list_provider_models", {
provider: "cline",
includeCloudModels: true,
})) as { models: Array<{ id: string }> };
expect(cloud.models).toContainEqual(
expect.objectContaining({ id: "cline-cloud/cloud-only" }),
});
expect(command).toHaveBeenLastCalledWith(
"providers.models.list",
{ providerId: "cline", includeCloudModels: true },
undefined,
{ retryAfterDispatch: false },
);
});
});
+25 -74
View File
@@ -18,7 +18,7 @@ import {
posix,
sep,
} from "node:path";
import { isDeepStrictEqual, promisify } from "node:util";
import { promisify } from "node:util";
import type {
ClineAccountActionRequest,
CoreSettingsSnapshot,
@@ -31,23 +31,19 @@ import type {
SaveProviderSettingsActionRequest,
} from "@cline/core";
import {
addLocalProvider,
ClineAccountService,
type ClineAccountUser,
clearAccountTelemetryIdentity,
createConfiguredStreamingTranscriptionSession,
createHubProvidersApi,
createUserInstructionConfigService,
ensureCustomProvidersLoaded,
executeClineAccountAction,
fetchClineRecommendedModels,
getCoreBuiltinToolCatalog,
getLocalProviderModels,
getLocalTranscriptionModels,
getProviderAuthHandler,
identifyAccount,
isModelToolEnabledGlobally,
listHookConfigFiles,
listLocalProviders,
normalizeOAuthProvider,
ProviderSettingsManager,
parseMcpServerRegistration,
@@ -64,13 +60,11 @@ import {
SessionImportService,
type SessionImportTool,
SqliteSessionStore,
saveLocalProviderSettings,
saveVoiceInputSettings,
setAutoUpdateEnabledGlobally,
setMcpServerDisabled,
setTelemetryOptOutGlobally,
transcribeConfiguredVoiceInput,
updateLocalProvider,
updateMcpSettingsFileSync,
upgradeManagedHub,
} from "@cline/core";
@@ -139,7 +133,6 @@ import {
resolveKeybindingsPath,
writeKeybindings,
} from "./keybindings";
import { clearLegacyProviderCredentials } from "./legacy-provider-credentials";
import {
installMarketplaceEntryForDesktopCommand,
listMarketplaceInstalledEntries,
@@ -2931,9 +2924,8 @@ export async function handleCommand(
// ── Provider management ────────────────────────────────────────────
if (command === "list_provider_catalog") {
const manager = new ProviderSettingsManager();
await ensureCustomProvidersLoaded(manager);
return await listLocalProviders(manager, { isClinePassEnabled: true });
const providers = createHubProvidersApi(await ensureSharedHubClient(ctx));
return await providers.list({ isClinePassEnabled: true });
}
if (command === "list_transcription_models") {
const manager = new ProviderSettingsManager();
@@ -2944,22 +2936,17 @@ export async function handleCommand(
);
}
if (command === "list_provider_models") {
const manager = new ProviderSettingsManager();
const providerId = String(args?.provider ?? "").trim();
const includeCloudModels =
providerId === "cline" &&
args?.includeCloudModels === true &&
isCloudAgentsEnabled();
return await getLocalProviderModels(
providerId,
manager.getProviderConfig(providerId, { includeKnownModels: false }),
{ loadLatest: includeCloudModels },
);
const providers = createHubProvidersApi(await ensureSharedHubClient(ctx));
return await providers.listModels({
providerId: String(args?.provider ?? "").trim(),
includeCloudModels:
args?.includeCloudModels === true && isCloudAgentsEnabled(),
});
}
if (command === "list_cline_recommended_models") {
// Tiered picker data (recommended / free / clinePass) with
// display-ready names; falls back to a bundled list offline.
return await fetchClineRecommendedModels();
return await createHubProvidersApi(
await ensureSharedHubClient(ctx),
).recommendedModels();
}
if (command === "create_streaming_transcription_session") {
const manager = new ProviderSettingsManager();
@@ -3097,60 +3084,26 @@ export async function handleCommand(
return result;
}
if (command === "save_provider_settings") {
const manager = new ProviderSettingsManager();
const providerId = String(args?.provider ?? "").trim();
const storageProviderId =
getProviderAuthHandler(providerId)?.storageProviderId ?? providerId;
const previous =
storageProviderId === "cline"
? manager.getProviderSettings(storageProviderId)
: undefined;
const saved = await saveLocalProviderSettings(manager, {
const providers = createHubProvidersApi(await ensureSharedHubClient(ctx));
const { clineCredentialsChanged, ...saved } = await providers.saveSettings({
...readProviderSettingsUpdate(args),
providerId,
providerId: String(args?.provider ?? "").trim(),
enabled: typeof args?.enabled === "boolean" ? args.enabled : undefined,
apiKey: typeof args?.api_key === "string" ? args.api_key : undefined,
baseUrl: typeof args?.base_url === "string" ? args.base_url : undefined,
});
if (!saved.enabled) {
// Cline Pass keeps its credentials under "cline", so removing only
// its own entry would leave the account signed in.
if (storageProviderId !== saved.providerId) {
await saveLocalProviderSettings(manager, {
providerId: storageProviderId,
enabled: false,
});
}
// Removing a providers.json entry lets the legacy import restore it
// from the extension's secrets.json on the next command unless those
// credentials go too. A failed write throws so the webview reports
// the sign-out as failed and resyncs.
clearLegacyProviderCredentials(storageProviderId);
if (clineCredentialsChanged) {
await resetCloudSessionManager(ctx);
broadcastEvent(ctx, "cloud_sessions_changed", {});
}
if (storageProviderId === "cline") {
const current = manager.getProviderSettings(storageProviderId);
// Ordinary provider preferences must not interrupt cloud sessions.
if (
previous?.apiKey !== current?.apiKey ||
!isDeepStrictEqual(previous?.auth, current?.auth)
) {
await resetCloudSessionManager(ctx);
broadcastEvent(ctx, "cloud_sessions_changed", {});
}
}
// Sign-out is a `save_provider_settings` that blanks the cline auth block
// (see signOut in webview settings/account-view.tsx), so this is the
// authoritative signal — it fires the moment credentials are cleared
// rather than waiting for the next account fetch.
if (saved.providerId === "cline" || saved.providerId === "cline-pass") {
syncAccountContextFromSettings(ctx, manager);
syncAccountContextFromSettings(ctx, new ProviderSettingsManager());
}
return saved;
}
if (command === "add_provider") {
const manager = new ProviderSettingsManager();
await ensureCustomProvidersLoaded(manager);
return await addLocalProvider(manager, {
const providers = createHubProvidersApi(await ensureSharedHubClient(ctx));
return await providers.add({
providerId: String(args?.provider_id ?? ""),
name: String(args?.name ?? ""),
baseUrl: String(args?.base_url ?? ""),
@@ -3186,11 +3139,9 @@ export async function handleCommand(
});
}
if (command === "update_provider_models") {
const providerId = String(args?.provider ?? "").trim();
const manager = new ProviderSettingsManager();
await ensureCustomProvidersLoaded(manager);
return await updateLocalProvider(manager, {
providerId,
const providers = createHubProvidersApi(await ensureSharedHubClient(ctx));
return await providers.updateModels({
providerId: String(args?.provider ?? "").trim(),
models: Array.isArray(args?.models)
? (args.models as string[])
: undefined,
+1
View File
@@ -146,6 +146,7 @@ Design rules:
- `daemon/` contains detached daemon startup, entrypoint, and local runtime handler wiring
- `discovery/` contains endpoint defaults, discovery records, and workspace owner resolution
- `server/` contains WebSocket server startup, native/browser socket adapters, server transport, server helpers, and `handlers/` for hub command dispatch
- Provider/model management is exposed by the shared daemon's typed `providers.*` commands: catalog listing, model discovery, recommended models, provider settings writes, custom provider creation, and model overrides. `createHubProvidersApi` uses an existing registered connection, does not retry dispatched mutations, and never falls back to host-side file writes. Desktop calls its local Hub even when an SSH environment is selected. Credential removal, shared Cline Pass storage cleanup, and legacy-secret cleanup execute in the daemon; Desktop retains account/feature-flag and cloud-view reactions. OAuth and voice operations remain separate migrations.
- settings mutations belong in core services and hub commands, not in host-specific file writes. Hosts should call the core settings facade or the `settings.*` hub command family and react to `settings.changed`.
## Runtime Flows
+30
View File
@@ -303,6 +303,36 @@ start are launched through the Cline CLI.
The hub `/health` endpoint includes `hubId`, the instance identity also returned by authenticated `/status` and discovery. Restart callers can compare it before and after shutdown to distinguish a replacement at the same address from the original hub.
### Hub provider and model management
Use `createHubProvidersApi(client)` from `@cline/core` or `@cline/core/hub`
with an existing registered `NodeHubClient`. Provider settings belong to the
machine running that Hub; Desktop uses its local connection even while viewing
an SSH environment.
| Method | Hub command |
| --- | --- |
| `list({ isClinePassEnabled })` | `providers.list` |
| `listModels({ providerId, includeCloudModels })` | `providers.models.list` |
| `recommendedModels()` | `providers.models.recommended` |
| `saveSettings({ providerId, ...settings })` | `providers.settings.save` |
| `add({ providerId, name, baseUrl, ...options })` | `providers.add` |
| `updateModels({ providerId, models })` | `providers.models.update` |
The network-free catalog includes saved voice selection and authentication
metadata. Model discovery uses current provider credentials and omits bundled
known-model overrides; Cline cloud models are included only when explicitly
requested. Recommended models retain the bundled offline fallback.
Settings writes merge nested configuration. Null or empty-string nested values
remove individual fields. Disabling Cline Pass also removes its shared Cline
credentials, and disabling a provider clears applicable legacy secrets so they
cannot restore the removed credentials. `saveSettings` returns
`clineCredentialsChanged` alongside the saved provider id, enabled state, and
settings path, allowing hosts to update account and cloud views. Provider API
calls do not automatically resend dispatched requests after a connection loss.
OAuth login and audio transcription remain separate APIs.
### Provider authentication metadata for host UIs
`@cline/shared` (including its browser entry point) exports `ProviderAuthInfo`,
@@ -0,0 +1,38 @@
import type {
HubCommandInput,
HubCommandOutputMap,
HubProviderCommandName,
} from "@cline/shared";
import { HubCommandError, type NodeHubClient } from "./index";
/** Uses the caller's registered local Hub connection; mutations are never retried after dispatch. */
export function createHubProvidersApi(client: Pick<NodeHubClient, "command">) {
async function command<T extends HubProviderCommandName>(
name: T,
input: HubCommandInput<T>,
): Promise<HubCommandOutputMap[T]> {
const reply = await client.command(name, { ...input }, undefined, {
retryAfterDispatch: false,
});
if (!reply.ok)
throw new HubCommandError(
name,
reply.error?.code,
reply.error?.message ?? `Hub command failed: ${name}`,
);
return reply.payload as unknown as HubCommandOutputMap[T];
}
return {
list: (input: HubCommandInput<"providers.list"> = {}) =>
command("providers.list", input),
listModels: (input: HubCommandInput<"providers.models.list">) =>
command("providers.models.list", input),
recommendedModels: () => command("providers.models.recommended", {}),
saveSettings: (input: HubCommandInput<"providers.settings.save">) =>
command("providers.settings.save", input),
add: (input: HubCommandInput<"providers.add">) =>
command("providers.add", input),
updateModels: (input: HubCommandInput<"providers.models.update">) =>
command("providers.models.update", input),
};
}
+1
View File
@@ -42,6 +42,7 @@ export {
export * from "./client";
export * from "./client/connect";
export * from "./client/managed-hub-build-watcher";
export { createHubProvidersApi } from "./client/providers";
export * from "./client/session-client";
export * from "./client/ui-client";
export * from "./daemon";
@@ -0,0 +1,200 @@
import { isDeepStrictEqual } from "node:util";
import {
type HubCommandEnvelope,
type HubReplyEnvelope,
ProviderCapabilitySchema,
ProviderClientSchema,
ProviderProtocolSchema,
type ProviderSettingsObjectPatch,
} from "@cline/shared";
import { z } from "zod";
import { getProviderAuthHandler } from "../../../auth/provider-auth-registry";
import { fetchClineRecommendedModels } from "../../../services/llms/cline-recommended-models";
import { ProviderSettingsSchema } from "../../../services/llms/provider-settings";
import { clearLegacyProviderCredentials } from "../../../services/providers/legacy-provider-credentials";
import {
addLocalProvider,
ensureCustomProvidersLoaded,
getLocalProviderModels,
listLocalProviders,
saveLocalProviderSettings,
updateLocalProvider,
} from "../../../services/providers/local-provider-service";
import { ProviderSettingsManager } from "../../../services/storage/provider-settings-manager";
import { errorReply, okReply } from "./context";
const providerId = z.string().trim().min(1);
const listSchema = z
.object({ isClinePassEnabled: z.boolean().optional() })
.strict();
const modelsSchema = z
.object({
providerId,
includeCloudModels: z.boolean().optional(),
})
.strict();
/** Match the merge/delete semantics of saveLocalProviderSettings before validating persisted state. */
function objectPatch<T extends z.ZodRawShape>(schema: z.ZodObject<T>) {
const fields = Object.fromEntries(
Object.entries(schema.shape).map(([key, field]) => [
key,
z.union([field, z.null(), z.literal("")]).optional(),
]),
);
return z.object(fields).strict() as z.ZodType<
ProviderSettingsObjectPatch<z.infer<z.ZodObject<T>>>
>;
}
const saveSchema = ProviderSettingsSchema.omit({
provider: true,
modelCatalog: true,
})
.extend({
providerId,
enabled: z.boolean().optional(),
auth: objectPatch(ProviderSettingsSchema.shape.auth.unwrap()).optional(),
reasoning: objectPatch(
ProviderSettingsSchema.shape.reasoning.unwrap(),
).optional(),
aws: objectPatch(ProviderSettingsSchema.shape.aws.unwrap()).optional(),
gcp: objectPatch(ProviderSettingsSchema.shape.gcp.unwrap()).optional(),
azure: objectPatch(ProviderSettingsSchema.shape.azure.unwrap()).optional(),
sap: objectPatch(ProviderSettingsSchema.shape.sap.unwrap()).optional(),
oca: objectPatch(ProviderSettingsSchema.shape.oca.unwrap()).optional(),
headers: z.record(z.string(), z.string().nullable()).optional(),
capabilities: z
.array(
z.enum([
"reasoning",
"prompt-cache",
"streaming",
"tools",
"vision",
"computer-use",
"oauth",
]),
)
.optional(),
// An empty endpoint clears the persisted override.
baseUrl: z.union([z.literal(""), z.url()]).optional(),
})
.strict();
const addSchema = z
.object({
providerId,
name: z.string().trim().min(1),
baseUrl: z.url(),
apiKey: z.string().optional(),
headers: z.record(z.string(), z.string()).optional(),
timeoutMs: z.number().positive().optional(),
models: z.array(z.string()).optional(),
defaultModelId: z.string().optional(),
modelsSourceUrl: z.string().optional(),
protocol: ProviderProtocolSchema.optional(),
client: ProviderClientSchema.optional(),
capabilities: z.array(ProviderCapabilitySchema).optional(),
})
.strict();
const updateModelsSchema = z
.object({
providerId,
models: z.array(z.string()).optional(),
})
.strict();
/** Provider configuration is machine-local and independent of session workspaces. */
export async function handleProviderCommand(
envelope: HubCommandEnvelope,
): Promise<HubReplyEnvelope> {
try {
const payload = envelope.payload ?? {};
const manager = new ProviderSettingsManager();
switch (envelope.command) {
case "providers.list": {
const options = listSchema.parse(payload);
await ensureCustomProvidersLoaded(manager);
return okReply(envelope, await listLocalProviders(manager, options));
}
case "providers.models.list": {
const input = modelsSchema.parse(payload);
await ensureCustomProvidersLoaded(manager);
return okReply(
envelope,
await getLocalProviderModels(
input.providerId,
manager.getProviderConfig(input.providerId, {
includeKnownModels: false,
}),
{
loadLatest:
input.providerId === "cline" &&
input.includeCloudModels === true,
},
),
);
}
case "providers.models.recommended": {
z.object({}).strict().parse(payload);
return okReply(envelope, {
...(await fetchClineRecommendedModels({
providerSettingsManager: manager,
})),
});
}
case "providers.settings.save": {
const input = saveSchema.parse(payload);
const storageProviderId =
getProviderAuthHandler(input.providerId)?.storageProviderId ??
input.providerId;
const previous =
storageProviderId === "cline"
? manager.getProviderSettings("cline")
: undefined;
const saved = await saveLocalProviderSettings(manager, input);
if (!saved.enabled) {
if (storageProviderId !== saved.providerId) {
await saveLocalProviderSettings(manager, {
providerId: storageProviderId,
enabled: false,
});
}
clearLegacyProviderCredentials(storageProviderId);
}
const current =
storageProviderId === "cline"
? manager.getProviderSettings("cline")
: undefined;
return okReply(envelope, {
...saved,
clineCredentialsChanged:
storageProviderId === "cline" &&
(previous?.apiKey !== current?.apiKey ||
!isDeepStrictEqual(previous?.auth, current?.auth)),
});
}
case "providers.add": {
const input = addSchema.parse(payload);
await ensureCustomProvidersLoaded(manager);
return okReply(envelope, await addLocalProvider(manager, input));
}
case "providers.models.update": {
const input = updateModelsSchema.parse(payload);
await ensureCustomProvidersLoaded(manager);
return okReply(envelope, await updateLocalProvider(manager, input));
}
default:
return errorReply(
envelope,
"unsupported_command",
`Unsupported provider command: ${envelope.command}`,
);
}
} catch (error) {
return errorReply(
envelope,
error instanceof z.ZodError ? "invalid_payload" : "provider_error",
error instanceof Error ? error.message : String(error),
);
}
}
@@ -80,6 +80,7 @@ import {
type PendingApproval,
type PendingCapabilityRequest,
} from "./handlers/context";
import { handleProviderCommand } from "./handlers/provider-handlers";
import {
handleSessionMcpAppsCallTool,
handleSessionMcpAppsReadResource,
@@ -813,6 +814,13 @@ export class HubServerTransport implements NativeHubTransport {
"client_not_registered",
"Register before accessing cloud sessions",
);
if (authority === null && envelope.command.startsWith("providers.")) {
return errorReply(
envelope,
"client_not_registered",
"Register before accessing provider settings",
);
}
const clientId = envelope.clientId?.trim();
// Omitted authority is reserved for trusted in-process callers. A remote
// transport passes null until registration so caller-controlled envelope
@@ -897,6 +905,13 @@ export class HubServerTransport implements NativeHubTransport {
}
}
switch (envelope.command) {
case "providers.list":
case "providers.models.list":
case "providers.models.recommended":
case "providers.settings.save":
case "providers.add":
case "providers.models.update":
return await handleProviderCommand(envelope);
case "client.register": {
const reply = handleClientRegister(this.ctx, envelope);
this.tasks.notifyAutomationReadinessChanged();
@@ -0,0 +1,285 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LocalRuntimeHost } from "../../runtime/host/local-runtime-host";
import { resetClineRecommendedModelsCacheForTests } from "../../services/llms/cline-recommended-models";
import { clearLiveModelsCatalogCache } from "../../services/llms/provider-defaults";
import { ProviderSettingsManager } from "../../services/storage/provider-settings-manager";
import { FileSessionService } from "../../session/services/file-session-service";
import { HubCommandError, NodeHubClient } from "../client";
import { createHubProvidersApi } from "../client/providers";
import { createLocalHubScheduleRuntimeHandlers } from "../daemon/runtime-handlers";
import { createInMemoryHubOwnerContext } from "../discovery";
import { HubServerTransport } from "./hub-server-transport";
import { startHubWebSocketServer } from "./hub-websocket-server";
let root: string;
let transport: HubServerTransport;
let api: ReturnType<typeof createHubProvidersApi>;
const send = vi.fn();
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "hub-providers-"));
vi.stubEnv("CLINE_DATA_DIR", root);
vi.stubEnv("CLINE_DIR", root);
vi.stubEnv("CLINE_HOME_DIR", root);
send.mockReset();
vi.stubGlobal(
"fetch",
vi.fn(async () => Response.json({})),
);
transport = new HubServerTransport({
sessionHost: new LocalRuntimeHost({
sessionService: new FileSessionService(join(root, "sessions")),
}),
workspaceRoot: root,
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
eventLog: false,
runQueue: false,
sessionSearchOptions: { dbPath: ":memory:" },
});
send.mockImplementation(async (command, payload) =>
transport.handleCommand({
version: "v1",
clientId: "providers-test",
command,
payload,
}),
);
api = createHubProvidersApi({ command: send } as Pick<
NodeHubClient,
"command"
>);
});
afterEach(async () => {
await transport?.stop();
clearLiveModelsCatalogCache();
resetClineRecommendedModelsCacheForTests();
vi.unstubAllGlobals();
vi.unstubAllEnvs();
await rm(root, { recursive: true, force: true });
});
describe("hub provider management", () => {
it("creates a provider, discovers its models, and persists overrides across calls", async () => {
await api.add({
providerId: "hub-custom",
name: "Hub Custom",
baseUrl: "https://example.test/v1",
apiKey: "secret",
models: ["first"],
});
expect((await api.list()).providers).toContainEqual(
expect.objectContaining({ id: "hub-custom" }),
);
expect(
(await api.listModels({ providerId: "hub-custom" })).models.map(
(m) => m.id,
),
).toEqual(["first"]);
await api.updateModels({
providerId: "hub-custom",
models: ["second", "third"],
});
expect(
(await api.listModels({ providerId: "hub-custom" })).models.map(
(m) => m.id,
),
).toEqual(["second", "third"]);
await api.saveSettings({
providerId: "hub-custom",
apiKey: "replacement",
model: "second",
});
expect(
new ProviderSettingsManager().getProviderSettings("hub-custom"),
).toMatchObject({ apiKey: "replacement", model: "second" });
expect(send).toHaveBeenCalledWith(
"providers.add",
expect.any(Object),
undefined,
{ retryAfterDispatch: false },
);
});
it("serves the provider API over a registered WebSocket connection", async () => {
const server = await startHubWebSocketServer({
host: "127.0.0.1",
port: 0,
owner: createInMemoryHubOwnerContext("provider-rpc-test"),
workspaceRoot: root,
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
eventLog: false,
runQueue: false,
sessionSearchOptions: { dbPath: ":memory:" },
});
const client = new NodeHubClient({
url: server.url,
authToken: server.authToken,
clientType: "provider-test",
});
try {
await client.connect();
const remote = createHubProvidersApi(client);
await remote.saveSettings({
providerId: "anthropic",
apiKey: "from-websocket",
});
expect(
new ProviderSettingsManager().getProviderSettings("anthropic")?.apiKey,
).toBe("from-websocket");
expect((await remote.list()).providers).toContainEqual(
expect.objectContaining({ id: "anthropic", enabled: true }),
);
} finally {
await client.dispose();
await server.close();
}
});
it("includes cloud-only models only when requested", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: string | URL | Request) => {
if (String(input) === "https://models.dev/api.json")
return Response.json({});
return Response.json({
clineCloud: [{ id: "cline-cloud/cloud-only", name: "Cloud Only" }],
});
}),
);
const local = await api.listModels({ providerId: "cline" });
expect(
local.models.some((model) => model.id.startsWith("cline-cloud/")),
).toBe(false);
const cloud = await api.listModels({
providerId: "cline",
includeCloudModels: true,
});
expect(cloud.models).toContainEqual(
expect.objectContaining({ id: "cline-cloud/cloud-only" }),
);
});
it("preserves settings patch/clear behavior and reports only credential changes", async () => {
expect(
await api.saveSettings({
providerId: "cline",
apiKey: "old",
auth: { accountId: "account" },
}),
).toMatchObject({ clineCredentialsChanged: true });
expect(
await api.saveSettings({
providerId: "cline",
baseUrl: "https://example.test",
}),
).toMatchObject({ clineCredentialsChanged: false });
expect(
await api.saveSettings({ providerId: "cline", apiKey: "new" }),
).toMatchObject({ clineCredentialsChanged: true });
await api.saveSettings({ providerId: "cline", baseUrl: "" });
expect(
new ProviderSettingsManager().getProviderSettings("cline")?.baseUrl,
).toBeUndefined();
});
it("merges nested settings and accepts the forms' field-clear values", async () => {
await api.saveSettings({
providerId: "bedrock",
headers: { Keep: "yes", Remove: "no" },
aws: {
region: "us-east-1",
endpoint: "https://example.test",
usePromptCache: true,
},
});
await api.saveSettings({
providerId: "bedrock",
headers: { Remove: null },
aws: { endpoint: "", usePromptCache: null },
});
const settings = new ProviderSettingsManager().getProviderSettings(
"bedrock",
);
expect(settings?.headers).toEqual({ Keep: "yes" });
expect(settings?.aws).toEqual({ region: "us-east-1" });
});
it("clears shared Cline Pass credentials and legacy secrets on disable", async () => {
const manager = new ProviderSettingsManager();
manager.saveProviderSettings({
provider: "cline",
apiKey: "secret",
auth: { accountId: "account", accessToken: "token" },
});
await writeFile(
join(root, "secrets.json"),
JSON.stringify({
"cline:clineAccountId": "legacy",
clineApiKey: "legacy-key",
unrelated: "keep",
}),
);
expect(
await api.saveSettings({ providerId: "cline-pass", enabled: false }),
).toMatchObject({ enabled: false, clineCredentialsChanged: true });
expect(
new ProviderSettingsManager().getProviderSettings("cline"),
).toBeUndefined();
expect(
JSON.parse(await readFile(join(root, "secrets.json"), "utf8")),
).toEqual({ unrelated: "keep" });
});
it("returns recommended models with the offline fallback", async () => {
const result = await api.recommendedModels();
expect(result.recommended.length).toBeGreaterThan(0);
expect(result).toMatchObject({
free: expect.any(Array),
clinePass: expect.any(Array),
});
});
it("rejects invalid writes before changing persisted settings", async () => {
await api.saveSettings({ providerId: "cline", apiKey: "keep" });
const result = await transport.handleCommand({
version: "v1",
command: "providers.settings.save",
payload: { providerId: "cline", enabled: "no" },
});
expect(result).toMatchObject({
ok: false,
error: { code: "invalid_payload" },
});
expect(
new ProviderSettingsManager().getProviderSettings("cline")?.apiKey,
).toBe("keep");
});
it("requires registration on browser connections for provider access", async () => {
const result = await transport.handleCommand(
{ version: "v1", command: "providers.list" },
null,
);
expect(result).toMatchObject({
ok: false,
error: { code: "client_not_registered" },
});
});
it("propagates failures without falling back to local writes", async () => {
send.mockResolvedValueOnce({
ok: false,
error: { code: "provider_error", message: "write failed" },
});
await expect(
api.saveSettings({ providerId: "cline", apiKey: "never" }),
).rejects.toBeInstanceOf(HubCommandError);
expect(
new ProviderSettingsManager().getProviderSettings("cline"),
).toBeUndefined();
expect(send).toHaveBeenCalledTimes(1);
});
});
+1
View File
@@ -1049,6 +1049,7 @@ export {
truncateCommandOutput,
} from "./extensions/tools";
export { createHubPluginCommandsApi } from "./hub/client/plugin-commands";
export { createHubProvidersApi } from "./hub/client/providers";
export * from "./remote/remote-environments";
export { ensureLoginShellPath } from "./remote/shell-path";
export { isClineAccountFeatureEnabled } from "./services/feature-flags/cline-account-feature-flags";
@@ -4,6 +4,10 @@ import {
getGeneratedProviderModels,
VERCEL_OPENROUTER_MODEL_ID_ALIAS_RULES,
} from "@cline/llms";
import type {
ClineRecommendedModel,
ClineRecommendedModelsData,
} from "@cline/shared";
import {
getClineEnvironmentConfig,
type ProviderModel,
@@ -13,19 +17,10 @@ import { ProviderSettingsManager } from "../storage/provider-settings-manager";
import { getLiveModelsCatalog } from "./provider-defaults";
import type { ModelInfo } from "./provider-settings";
export interface ClineRecommendedModel {
id: string;
/** Display-ready model name, resolved against the model catalog. */
name: string;
description: string;
tags: string[];
}
export interface ClineRecommendedModelsData {
recommended: ClineRecommendedModel[];
free: ClineRecommendedModel[];
clinePass: ClineRecommendedModel[];
}
export type {
ClineRecommendedModel,
ClineRecommendedModelsData,
} from "@cline/shared";
type ModelsCatalog = Record<string, Record<string, ModelInfo>>;
+70 -6
View File
@@ -1,6 +1,12 @@
import type { AgentMessage } from "./agent";
import type { GatewayModelSelection, JsonValue } from "./llms/gateway";
import type { ReasoningEffort } from "./llms/reasoning-options";
import type {
AddProviderActionRequest,
ProviderCatalogResponse,
ProviderModelsResponse,
SaveProviderSettingsActionRequest,
} from "./rpc/runtime";
import type { RuntimeConfigExtensionKind } from "./session/runtime-config";
import type {
AgendaAutomationPolicy,
@@ -18,6 +24,7 @@ export const MIN_CLIENT_HUB_PROTOCOL_VERSION: HubProtocolVersion = "v1";
export const MAX_CLIENT_HUB_PROTOCOL_VERSION: HubProtocolVersion = "v1";
export type HubCapabilityName =
| HubProviderCommandName
| "client.register"
| "client.list"
| "session.create"
@@ -51,6 +58,12 @@ export type HubCapabilityName =
| "stream.replay";
export const HUB_CAPABILITIES: readonly HubCapabilityName[] = [
"providers.list",
"providers.models.list",
"providers.models.recommended",
"providers.settings.save",
"providers.add",
"providers.models.update",
"client.register",
"client.list",
"session.create",
@@ -576,12 +589,48 @@ export interface HubPluginsReloadInput {
plugin: string;
}
/**
* Strongly typed task command payloads. This map is intentionally extensible so
* other Hub command families can adopt typed payloads without changing the wire
* envelope.
*/
export interface ClineRecommendedModel {
id: string;
/** Display-ready model name resolved against the model catalog. */
name: string;
description: string;
tags: string[];
}
export interface ClineRecommendedModelsData {
recommended: ClineRecommendedModel[];
free: ClineRecommendedModel[];
clinePass: ClineRecommendedModel[];
}
export interface HubProviderSaveResult {
providerId: string;
enabled: boolean;
settingsPath: string;
/** Whether the shared Cline account credentials changed during this write. */
clineCredentialsChanged: boolean;
}
export interface HubProviderModelsUpdateInput {
providerId: string;
models?: string[];
}
export interface HubProviderMutationResult {
providerId: string;
settingsPath: string;
modelsPath: string;
modelsCount: number;
}
/** Typed Hub command payloads shared by native and browser clients. */
export interface HubCommandInputMap {
"providers.list": { isClinePassEnabled?: boolean };
"providers.models.list": { providerId: string; includeCloudModels?: boolean };
"providers.models.recommended": Record<string, never>;
"providers.settings.save": Omit<SaveProviderSettingsActionRequest, "action">;
"providers.add": Omit<AddProviderActionRequest, "action">;
"providers.models.update": HubProviderModelsUpdateInput;
"session.search": HubSessionSearchInput;
"task.create": HubTaskCreateInput;
"task.list": AgendaTaskListInput;
@@ -597,8 +646,14 @@ export interface HubCommandInputMap {
"plugins.reload": HubPluginsReloadInput;
}
/** Typed task command results returned in {@link HubReplyEnvelope.payload}. */
/** Typed command results returned in {@link HubReplyEnvelope.payload}. */
export interface HubCommandOutputMap {
"providers.list": ProviderCatalogResponse;
"providers.models.list": ProviderModelsResponse;
"providers.models.recommended": ClineRecommendedModelsData;
"providers.settings.save": HubProviderSaveResult;
"providers.add": HubProviderMutationResult;
"providers.models.update": HubProviderMutationResult;
"session.search": { hits: HubSessionSearchHit[] };
"task.create": { task: AgendaTaskRecord };
"task.list": { tasks: AgendaTaskRecord[] };
@@ -620,7 +675,16 @@ export type HubCommandInput<TCommand extends HubTypedCommandName> =
export type HubCommandOutput<TCommand extends HubTypedCommandName> =
HubCommandOutputMap[TCommand];
export type HubProviderCommandName =
| "providers.list"
| "providers.models.list"
| "providers.models.recommended"
| "providers.settings.save"
| "providers.add"
| "providers.models.update";
export type HubCommandName =
| HubProviderCommandName
| "cloud.command"
| "cloud.watch"
| "client.register"
+1
View File
@@ -407,6 +407,7 @@ export type {
ProviderOAuthLoginResponse,
ProviderProtocol,
ProviderSettingsActionRequest,
ProviderSettingsObjectPatch,
RuntimeLoggerConfig,
SaveProviderSettingsActionRequest,
VoiceInputSelection,
+1
View File
@@ -457,6 +457,7 @@ export type {
ProviderOAuthLoginResponse,
ProviderProtocol,
ProviderSettingsActionRequest,
ProviderSettingsObjectPatch,
RuntimeLoggerConfig,
SaveProviderSettingsActionRequest,
VoiceInputSelection,
+20 -15
View File
@@ -321,19 +321,24 @@ export interface GetProviderModelsActionRequest {
providerId: string;
}
/** Nested settings are merged; null and empty strings remove a field. */
export type ProviderSettingsObjectPatch<T> = {
[K in keyof T]?: T[K] | null | "";
};
export interface SaveProviderSettingsActionRequest {
action: "saveProviderSettings";
providerId: string;
enabled?: boolean;
// Authentication
apiKey?: string;
auth?: {
auth?: ProviderSettingsObjectPatch<{
apiKey?: string;
accessToken?: string;
refreshToken?: string;
expiresAt?: number;
accountId?: string;
};
}>;
// Model configuration
model?: string;
protocol?: ProviderProtocol;
@@ -343,16 +348,16 @@ export interface SaveProviderSettingsActionRequest {
contextWindow?: number;
// Endpoint configuration
baseUrl?: string;
headers?: Record<string, string>;
headers?: Record<string, string | null>;
timeout?: number;
// Reasoning/thinking configuration
reasoning?: {
reasoning?: ProviderSettingsObjectPatch<{
enabled?: boolean;
effort?: ReasoningLevel;
budgetTokens?: number;
};
}>;
// AWS/Bedrock configuration
aws?: {
aws?: ProviderSettingsObjectPatch<{
accessKey?: string;
secretKey?: string;
sessionToken?: string;
@@ -364,19 +369,19 @@ export interface SaveProviderSettingsActionRequest {
useGlobalInference?: boolean;
endpoint?: string;
customModelBaseId?: string;
};
}>;
// GCP/Vertex configuration
gcp?: {
gcp?: ProviderSettingsObjectPatch<{
projectId?: string;
region?: string;
};
}>;
// Azure configuration
azure?: {
azure?: ProviderSettingsObjectPatch<{
apiVersion?: string;
useIdentity?: boolean;
};
}>;
// SAP AI Core configuration
sap?: {
sap?: ProviderSettingsObjectPatch<{
clientId?: string;
clientSecret?: string;
tokenUrl?: string;
@@ -385,12 +390,12 @@ export interface SaveProviderSettingsActionRequest {
useOrchestrationMode?: boolean;
api?: "orchestration" | "foundation-models";
defaultSettings?: Record<string, unknown>;
};
}>;
// OCA configuration
oca?: {
oca?: ProviderSettingsObjectPatch<{
mode?: "internal" | "external";
usePromptCache?: boolean;
};
}>;
// Region configuration
region?: string;
apiLine?: "china" | "international";