mirror of
https://github.com/cline/cline.git
synced 2026-10-25 10:26:36 +08:00
refactor(desktop): move provider and model management to hub
This commit is contained in:
@@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>>;
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -407,6 +407,7 @@ export type {
|
||||
ProviderOAuthLoginResponse,
|
||||
ProviderProtocol,
|
||||
ProviderSettingsActionRequest,
|
||||
ProviderSettingsObjectPatch,
|
||||
RuntimeLoggerConfig,
|
||||
SaveProviderSettingsActionRequest,
|
||||
VoiceInputSelection,
|
||||
|
||||
@@ -457,6 +457,7 @@ export type {
|
||||
ProviderOAuthLoginResponse,
|
||||
ProviderProtocol,
|
||||
ProviderSettingsActionRequest,
|
||||
ProviderSettingsObjectPatch,
|
||||
RuntimeLoggerConfig,
|
||||
SaveProviderSettingsActionRequest,
|
||||
VoiceInputSelection,
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user