diff --git a/apps/examples/desktop-app/sidecar/ARCHITECTURE.md b/apps/examples/desktop-app/sidecar/ARCHITECTURE.md index 9c5799a60e..c6f7405af7 100644 --- a/apps/examples/desktop-app/sidecar/ARCHITECTURE.md +++ b/apps/examples/desktop-app/sidecar/ARCHITECTURE.md @@ -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) | diff --git a/apps/examples/desktop-app/sidecar/commands-account.test.ts b/apps/examples/desktop-app/sidecar/commands-account.test.ts index f80ad05d70..7e0ec93331 100644 --- a/apps/examples/desktop-app/sidecar/commands-account.test.ts +++ b/apps/examples/desktop-app/sidecar/commands-account.test.ts @@ -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("./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(); }); diff --git a/apps/examples/desktop-app/sidecar/commands-settings.test.ts b/apps/examples/desktop-app/sidecar/commands-settings.test.ts index 51db1d546b..266565f73a 100644 --- a/apps/examples/desktop-app/sidecar/commands-settings.test.ts +++ b/apps/examples/desktop-app/sidecar/commands-settings.test.ts @@ -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 }, ); }); }); diff --git a/apps/examples/desktop-app/sidecar/commands.ts b/apps/examples/desktop-app/sidecar/commands.ts index f3194be352..29461376fe 100644 --- a/apps/examples/desktop-app/sidecar/commands.ts +++ b/apps/examples/desktop-app/sidecar/commands.ts @@ -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, diff --git a/sdk/ARCHITECTURE.md b/sdk/ARCHITECTURE.md index 3e193f8f3a..af3dd93275 100644 --- a/sdk/ARCHITECTURE.md +++ b/sdk/ARCHITECTURE.md @@ -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 diff --git a/sdk/DOC.md b/sdk/DOC.md index c547f10d82..6c6f8d519d 100644 --- a/sdk/DOC.md +++ b/sdk/DOC.md @@ -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`, diff --git a/sdk/packages/core/src/hub/client/providers.ts b/sdk/packages/core/src/hub/client/providers.ts new file mode 100644 index 0000000000..78e9d5d33e --- /dev/null +++ b/sdk/packages/core/src/hub/client/providers.ts @@ -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) { + async function command( + name: T, + input: HubCommandInput, + ): Promise { + 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), + }; +} diff --git a/sdk/packages/core/src/hub/index.ts b/sdk/packages/core/src/hub/index.ts index 40bf822616..611c1e91e5 100644 --- a/sdk/packages/core/src/hub/index.ts +++ b/sdk/packages/core/src/hub/index.ts @@ -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"; diff --git a/sdk/packages/core/src/hub/server/handlers/provider-handlers.ts b/sdk/packages/core/src/hub/server/handlers/provider-handlers.ts new file mode 100644 index 0000000000..ad3d60ea58 --- /dev/null +++ b/sdk/packages/core/src/hub/server/handlers/provider-handlers.ts @@ -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(schema: z.ZodObject) { + 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>> + >; +} + +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 { + 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), + ); + } +} diff --git a/sdk/packages/core/src/hub/server/hub-server-transport.ts b/sdk/packages/core/src/hub/server/hub-server-transport.ts index 9e21a0272c..c9de1de78a 100644 --- a/sdk/packages/core/src/hub/server/hub-server-transport.ts +++ b/sdk/packages/core/src/hub/server/hub-server-transport.ts @@ -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(); diff --git a/sdk/packages/core/src/hub/server/providers.test.ts b/sdk/packages/core/src/hub/server/providers.test.ts new file mode 100644 index 0000000000..84f0b98c84 --- /dev/null +++ b/sdk/packages/core/src/hub/server/providers.test.ts @@ -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; +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); + }); +}); diff --git a/sdk/packages/core/src/index.ts b/sdk/packages/core/src/index.ts index 4cbd22a836..b6b753d950 100644 --- a/sdk/packages/core/src/index.ts +++ b/sdk/packages/core/src/index.ts @@ -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"; diff --git a/sdk/packages/core/src/services/llms/cline-recommended-models.ts b/sdk/packages/core/src/services/llms/cline-recommended-models.ts index e75af5ce3b..73c85a01ce 100644 --- a/sdk/packages/core/src/services/llms/cline-recommended-models.ts +++ b/sdk/packages/core/src/services/llms/cline-recommended-models.ts @@ -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>; diff --git a/apps/examples/desktop-app/sidecar/legacy-provider-credentials.test.ts b/sdk/packages/core/src/services/providers/legacy-provider-credentials.test.ts similarity index 100% rename from apps/examples/desktop-app/sidecar/legacy-provider-credentials.test.ts rename to sdk/packages/core/src/services/providers/legacy-provider-credentials.test.ts diff --git a/apps/examples/desktop-app/sidecar/legacy-provider-credentials.ts b/sdk/packages/core/src/services/providers/legacy-provider-credentials.ts similarity index 100% rename from apps/examples/desktop-app/sidecar/legacy-provider-credentials.ts rename to sdk/packages/core/src/services/providers/legacy-provider-credentials.ts diff --git a/sdk/packages/shared/src/hub.ts b/sdk/packages/shared/src/hub.ts index 67d91edb32..9ce1849e7d 100644 --- a/sdk/packages/shared/src/hub.ts +++ b/sdk/packages/shared/src/hub.ts @@ -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; + "providers.settings.save": Omit; + "providers.add": Omit; + "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 = export type HubCommandOutput = 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" diff --git a/sdk/packages/shared/src/index.browser.ts b/sdk/packages/shared/src/index.browser.ts index 5c0f042ebf..630f47e909 100644 --- a/sdk/packages/shared/src/index.browser.ts +++ b/sdk/packages/shared/src/index.browser.ts @@ -407,6 +407,7 @@ export type { ProviderOAuthLoginResponse, ProviderProtocol, ProviderSettingsActionRequest, + ProviderSettingsObjectPatch, RuntimeLoggerConfig, SaveProviderSettingsActionRequest, VoiceInputSelection, diff --git a/sdk/packages/shared/src/index.ts b/sdk/packages/shared/src/index.ts index 5ccccd5841..ecebf48613 100644 --- a/sdk/packages/shared/src/index.ts +++ b/sdk/packages/shared/src/index.ts @@ -457,6 +457,7 @@ export type { ProviderOAuthLoginResponse, ProviderProtocol, ProviderSettingsActionRequest, + ProviderSettingsObjectPatch, RuntimeLoggerConfig, SaveProviderSettingsActionRequest, VoiceInputSelection, diff --git a/sdk/packages/shared/src/rpc/runtime.ts b/sdk/packages/shared/src/rpc/runtime.ts index 9c05d42110..d95fa2f1aa 100644 --- a/sdk/packages/shared/src/rpc/runtime.ts +++ b/sdk/packages/shared/src/rpc/runtime.ts @@ -321,19 +321,24 @@ export interface GetProviderModelsActionRequest { providerId: string; } +/** Nested settings are merged; null and empty strings remove a field. */ +export type ProviderSettingsObjectPatch = { + [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; + headers?: Record; 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; - }; + }>; // OCA configuration - oca?: { + oca?: ProviderSettingsObjectPatch<{ mode?: "internal" | "external"; usePromptCache?: boolean; - }; + }>; // Region configuration region?: string; apiLine?: "china" | "international";