From 9845a7362e04b766aafc532672ebf97a2728df26 Mon Sep 17 00:00:00 2001 From: Arafatkatze Date: Sat, 25 Apr 2026 12:43:44 -0700 Subject: [PATCH 1/6] Support local overrides for editable Cline providers --- src/cline-sdk/cline-provider-service.ts | 4 + src/cline-sdk/sdk-provider-boundary.ts | 212 ++++++++++++++++-- src/core/api-contract.ts | 4 + .../shared/cline-setup-section.test.tsx | 193 ++++++++++++++++ .../components/shared/cline-setup-section.tsx | 30 ++- 5 files changed, 420 insertions(+), 23 deletions(-) create mode 100644 web-ui/src/components/shared/cline-setup-section.test.tsx diff --git a/src/cline-sdk/cline-provider-service.ts b/src/cline-sdk/cline-provider-service.ts index 807cae4e4..00f2d51e9 100644 --- a/src/cline-sdk/cline-provider-service.ts +++ b/src/cline-sdk/cline-provider-service.ts @@ -747,12 +747,16 @@ export function createClineProviderService() { id: provider.id, name: provider.name, oauthSupported: (provider.capabilities ?? []).includes("oauth"), + custom: provider.custom, + client: provider.client, enabled: selectedProviderId.length > 0 ? selectedProviderId === provider.id : provider.id === "cline", defaultModelId: provider.defaultModelId ?? null, baseUrl: provider.baseUrl?.trim() || null, supportsBaseUrl: (provider.baseUrl?.trim().length ?? 0) > 0, env: provider.env, + capabilities: provider.capabilities, + modelsSourceUrl: provider.modelsSourceUrl?.trim() || null, })) .sort((left, right) => { if (left.id === "cline") { diff --git a/src/cline-sdk/sdk-provider-boundary.ts b/src/cline-sdk/sdk-provider-boundary.ts index 5f144c3af..2539ded85 100644 --- a/src/cline-sdk/sdk-provider-boundary.ts +++ b/src/cline-sdk/sdk-provider-boundary.ts @@ -2,7 +2,7 @@ // The rest of Kanban should talk to the SDK through local service modules so // auth, catalog, and provider-settings behavior stay behind one boundary. -import { readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import * as ClineCore from "@clinebot/core"; import { @@ -62,6 +62,9 @@ export interface SdkProviderCatalogItem { baseUrl?: string; env?: string[]; capabilities?: string[]; + client?: string; + modelsSourceUrl?: string; + custom?: boolean; } export interface SdkProviderModel { @@ -125,11 +128,43 @@ type LocalModelsFile = { capabilities?: SdkCustomProviderCapability[]; modelsSourceUrl?: string; }; - models: Record; + models: Record< + string, + { + id: string; + name: string; + supportsVision?: boolean; + supportsAttachments?: boolean; + supportsReasoning?: boolean; + } + >; } >; }; +type UpdateLocalProviderRequest = { + providerId: string; + name?: string; + baseUrl?: string; + apiKey?: string | null; + headers?: Record | null; + timeoutMs?: number | null; + models?: string[]; + defaultModelId?: string | null; + modelsSourceUrl?: string | null; + capabilities?: SdkCustomProviderCapability[]; +}; + +const MANAGED_OAUTH_PROVIDER_IDS = new Set(["cline", "oca", "openai-codex"]); +const OPENAI_COMPATIBLE_CLIENT = "openai-compatible"; +const CUSTOM_PROVIDER_CAPABILITIES = new Set([ + "streaming", + "tools", + "reasoning", + "vision", + "prompt-cache", +]); + export type SdkMcpTool = Tool; export interface SdkMcpServerRegistration { @@ -305,7 +340,12 @@ export async function completeClineDeviceAuth(input: { } export async function listSdkProviderCatalog(): Promise { - return await ClineCore.Llms.getAllProviders(); + const localModels = await readModelsRegistry(); + return (await ClineCore.Llms.getAllProviders()).map((provider: SdkProviderCatalogItem) => ({ + ...provider, + custom: Boolean(localModels.providers[provider.id.trim().toLowerCase()]), + modelsSourceUrl: localModels.providers[provider.id.trim().toLowerCase()]?.provider.modelsSourceUrl, + })); } export async function listSdkProviderModels(providerId: string): Promise { @@ -340,7 +380,131 @@ async function readModelsRegistry(): Promise { } async function writeModelsRegistry(state: LocalModelsFile): Promise { - await writeFile(resolveModelsPath(), `${JSON.stringify(state, null, 2)}\n`, "utf8"); + const modelsPath = resolveModelsPath(); + await mkdir(dirname(modelsPath), { recursive: true }); + await writeFile(modelsPath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); +} + +function normalizeProviderId(providerId: string): string { + return providerId.trim().toLowerCase(); +} + +function uniqueTrimmed(values: readonly string[] | undefined): string[] { + return [...new Set((values ?? []).map((value) => value.trim()).filter((value) => value.length > 0))]; +} + +function toCustomProviderCapabilities( + values: readonly string[] | null | undefined, +): SdkCustomProviderCapability[] | undefined { + const capabilities = values?.filter((value): value is SdkCustomProviderCapability => + CUSTOM_PROVIDER_CAPABILITIES.has(value as SdkCustomProviderCapability), + ); + return capabilities && capabilities.length > 0 ? [...new Set(capabilities)] : undefined; +} + +function isMissingLocalProviderError(error: unknown, providerId: string): boolean { + const message = error instanceof Error ? error.message : String(error); + return message === `provider "${providerId}" does not exist`; +} + +function titleCaseProviderId(providerId: string): string { + return providerId + .split(/[-_]/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +async function getOverrideableBuiltInProvider(providerId: string): Promise { + if (MANAGED_OAUTH_PROVIDER_IDS.has(providerId)) { + return null; + } + const provider = (await ClineCore.Llms.getProvider(providerId)) as SdkProviderCatalogItem | undefined; + if (!provider || provider.client !== OPENAI_COMPATIBLE_CLIENT) { + return null; + } + if ((provider.capabilities ?? []).includes("oauth")) { + return null; + } + return provider; +} + +async function readRegisteredProviderModels( + providerId: string, +): Promise> { + return (await ClineCore.Llms.getModelsForProvider(providerId)) as Record; +} + +async function resolveSeedModelIds(input: UpdateSdkCustomProviderInput, providerId: string): Promise { + const explicitModels = uniqueTrimmed(input.models); + if (explicitModels.length > 0) { + return explicitModels; + } + + const registeredModels = await readRegisteredProviderModels(providerId).catch( + (): Record => ({}), + ); + const registeredModelIds = uniqueTrimmed(Object.keys(registeredModels)); + if (registeredModelIds.length > 0) { + return registeredModelIds; + } + + return []; +} + +async function seedLocalProviderOverride(input: UpdateSdkCustomProviderInput): Promise { + const providerId = normalizeProviderId(input.providerId); + const state = await readModelsRegistry(); + if (state.providers[providerId]) { + return; + } + + const provider = await getOverrideableBuiltInProvider(providerId); + if (!provider) { + throw new Error(`provider "${providerId}" does not exist`); + } + + const resolvedModelIds = await resolveSeedModelIds(input, providerId); + const fallbackDefaultModelId = input.defaultModelId?.trim() || provider.defaultModelId?.trim() || ""; + const modelIds = + resolvedModelIds.length > 0 ? resolvedModelIds : fallbackDefaultModelId ? [fallbackDefaultModelId] : []; + const defaultModelId = + (input.defaultModelId?.trim() && modelIds.includes(input.defaultModelId.trim()) + ? input.defaultModelId.trim() + : provider.defaultModelId?.trim() && modelIds.includes(provider.defaultModelId.trim()) + ? provider.defaultModelId.trim() + : modelIds[0]) ?? ""; + if (!defaultModelId) { + throw new Error("at least one model is required"); + } + + const registeredModels = await readRegisteredProviderModels(providerId).catch( + (): Record => ({}), + ); + const baseUrl = input.baseUrl?.trim() || provider.baseUrl?.trim() || ""; + if (!baseUrl) { + throw new Error("baseUrl is required"); + } + + state.providers[providerId] = { + provider: { + name: input.name?.trim() || provider.name || titleCaseProviderId(providerId), + baseUrl, + defaultModelId, + capabilities: input.capabilities ?? toCustomProviderCapabilities(provider.capabilities), + modelsSourceUrl: input.modelsSourceUrl?.trim() || undefined, + }, + models: Object.fromEntries( + modelIds.map((modelId) => [ + modelId, + { + id: modelId, + name: registeredModels[modelId]?.name?.trim() || modelId, + }, + ]), + ), + }; + await writeModelsRegistry(state); } export async function addSdkCustomProvider(input: AddSdkCustomProviderInput): Promise { @@ -364,27 +528,35 @@ export async function updateSdkCustomProvider(input: UpdateSdkCustomProviderInpu ClineCore as { updateLocalProvider?: ( manager: ProviderSettingsManager, - request: { - providerId: string; - name?: string; - baseUrl?: string; - apiKey?: string | null; - headers?: Record | null; - timeoutMs?: number | null; - models?: string[]; - defaultModelId?: string | null; - modelsSourceUrl?: string | null; - capabilities?: SdkCustomProviderCapability[]; - }, + request: UpdateLocalProviderRequest, ) => Promise; } ).updateLocalProvider; if (updateLocalProvider) { - await updateLocalProvider(providerManager, input); + const providerId = normalizeProviderId(input.providerId); + try { + await updateLocalProvider(providerManager, input); + return; + } catch (error) { + if (!isMissingLocalProviderError(error, providerId) || !(await getOverrideableBuiltInProvider(providerId))) { + throw error; + } + const previousModelsState = await readModelsRegistry(); + const previousSettingsState = providerManager.read(); + await seedLocalProviderOverride(input); + try { + await updateLocalProvider(providerManager, input); + } catch (updateError) { + await writeModelsRegistry(previousModelsState); + providerManager.write(previousSettingsState); + ClineCore.Llms.unregisterProvider(providerId); + throw updateError; + } + } return; } - const providerId = input.providerId.trim().toLowerCase(); + const providerId = normalizeProviderId(input.providerId); const state = await readModelsRegistry(); const existing = state.providers[providerId]; if (!existing) { @@ -396,9 +568,7 @@ export async function updateSdkCustomProvider(input: UpdateSdkCustomProviderInpu const models = input.models?.map((model) => model.trim()).filter((model) => model.length > 0) ?? - Object.keys(existing.models) - .map((model) => model.trim()) - .filter((model) => model.length > 0); + uniqueTrimmed(Object.keys(existing.models)); if (models.length === 0) { throw new Error("at least one model is required"); } diff --git a/src/core/api-contract.ts b/src/core/api-contract.ts index ac2a2dfae..039fcd30c 100644 --- a/src/core/api-contract.ts +++ b/src/core/api-contract.ts @@ -673,6 +673,10 @@ export const runtimeClineProviderCatalogItemSchema = z.object({ baseUrl: z.string().nullable(), supportsBaseUrl: z.boolean(), env: z.array(z.string()).optional(), + custom: z.boolean().optional(), + client: z.string().optional(), + capabilities: z.array(z.string()).optional(), + modelsSourceUrl: z.string().nullable().optional(), }); export type RuntimeClineProviderCatalogItem = z.infer; diff --git a/web-ui/src/components/shared/cline-setup-section.test.tsx b/web-ui/src/components/shared/cline-setup-section.test.tsx new file mode 100644 index 000000000..8b422ff90 --- /dev/null +++ b/web-ui/src/components/shared/cline-setup-section.test.tsx @@ -0,0 +1,193 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ClineSetupSection } from "@/components/shared/cline-setup-section"; +import type { UseRuntimeSettingsClineControllerResult } from "@/hooks/use-runtime-settings-cline-controller"; +import type { RuntimeClineProviderCatalogItem } from "@/runtime/types"; + +vi.mock("@/runtime/runtime-config-query", () => ({ + openFileOnHost: vi.fn(), +})); + +function findButtonByText(container: ParentNode, text: string): HTMLButtonElement | null { + return (Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.trim() === text) ?? + null) as HTMLButtonElement | null; +} + +function createProvider(overrides: Partial = {}): RuntimeClineProviderCatalogItem { + return { + id: "litellm", + name: "LiteLLM", + oauthSupported: false, + enabled: true, + defaultModelId: "gpt-5.4", + baseUrl: "http://localhost:4000/v1", + supportsBaseUrl: true, + client: "openai-compatible", + capabilities: ["prompt-cache"], + ...overrides, + }; +} + +function createController( + provider: RuntimeClineProviderCatalogItem, + overrides: Partial = {}, +): UseRuntimeSettingsClineControllerResult { + const providerId = provider.id; + return { + currentProviderSettings: { + providerId, + modelId: provider.defaultModelId, + baseUrl: provider.baseUrl, + reasoningEffort: null, + apiKeyConfigured: false, + oauthProvider: null, + oauthAccessTokenConfigured: false, + oauthRefreshTokenConfigured: false, + oauthAccountId: null, + oauthExpiresAt: null, + }, + providerId, + setProviderId: vi.fn(), + modelId: provider.defaultModelId ?? "", + setModelId: vi.fn(), + apiKey: "", + setApiKey: vi.fn(), + baseUrl: provider.baseUrl ?? "", + setBaseUrl: vi.fn(), + region: "", + setRegion: vi.fn(), + reasoningEffort: "", + setReasoningEffort: vi.fn(), + awsAccessKey: "", + setAwsAccessKey: vi.fn(), + awsSecretKey: "", + setAwsSecretKey: vi.fn(), + awsSessionToken: "", + setAwsSessionToken: vi.fn(), + awsRegion: "", + setAwsRegion: vi.fn(), + awsProfile: "", + setAwsProfile: vi.fn(), + awsAuthentication: "", + setAwsAuthentication: vi.fn(), + awsEndpoint: "", + setAwsEndpoint: vi.fn(), + gcpProjectId: "", + setGcpProjectId: vi.fn(), + gcpRegion: "", + setGcpRegion: vi.fn(), + providerCatalog: [provider], + providerModels: [{ id: provider.defaultModelId ?? "gpt-5.4", name: provider.defaultModelId ?? "gpt-5.4" }], + isLoadingProviderCatalog: false, + isLoadingProviderModels: false, + isRunningOauthLogin: false, + deviceAuthInfo: null, + normalizedProviderId: providerId, + managedOauthProvider: null, + isOauthProviderSelected: false, + apiKeyConfigured: false, + oauthConfigured: false, + oauthAccountId: "", + oauthExpiresAt: "", + selectedModelSupportsReasoningEffort: false, + hasUnsavedChanges: false, + saveProviderSettings: vi.fn(async () => ({ ok: true })), + addCustomProvider: vi.fn(async () => ({ ok: true })), + updateCustomProvider: vi.fn(async () => ({ ok: true })), + runOauthLogin: vi.fn(async () => ({ ok: true })), + ...overrides, + }; +} + +describe("ClineSetupSection", () => { + let container: HTMLDivElement; + let root: Root; + let previousActEnvironment: boolean | undefined; + + beforeEach(() => { + previousActEnvironment = (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT; + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + document.body.innerHTML = ""; + if (previousActEnvironment === undefined) { + delete (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; + } else { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + previousActEnvironment; + } + }); + + it("shows edit controls for OpenAI-compatible built-in providers", async () => { + await act(async () => { + root.render( + , + ); + }); + + expect(findButtonByText(document.body, "Edit")).toBeInstanceOf(HTMLButtonElement); + }); + + it("does not show edit controls for non-OpenAI-compatible built-in providers", async () => { + await act(async () => { + root.render( + , + ); + }); + + expect(findButtonByText(document.body, "Edit")).toBeNull(); + }); + + it("does not show edit controls for managed OAuth providers", async () => { + await act(async () => { + root.render( + , + ); + }); + + expect(findButtonByText(document.body, "Edit")).toBeNull(); + }); + + it("shows custom-provider edit controls for local custom providers", async () => { + await act(async () => { + root.render( + , + ); + }); + + expect(findButtonByText(document.body, "Edit")).toBeInstanceOf(HTMLButtonElement); + }); +}); diff --git a/web-ui/src/components/shared/cline-setup-section.tsx b/web-ui/src/components/shared/cline-setup-section.tsx index 6f9be7e7d..8fa343719 100644 --- a/web-ui/src/components/shared/cline-setup-section.tsx +++ b/web-ui/src/components/shared/cline-setup-section.tsx @@ -21,10 +21,22 @@ import type { } from "@/hooks/use-runtime-settings-cline-controller"; import type { UseRuntimeSettingsClineMcpControllerResult } from "@/hooks/use-runtime-settings-cline-mcp-controller"; import { openFileOnHost } from "@/runtime/runtime-config-query"; -import type { RuntimeClineMcpServer, RuntimeClineReasoningEffort } from "@/runtime/types"; +import type { + RuntimeClineMcpServer, + RuntimeClineProviderCapability, + RuntimeClineReasoningEffort, +} from "@/runtime/types"; import { formatPathForDisplay } from "@/utils/path-display"; import { useCopyToClipboard } from "@/utils/react-use"; +const EDITABLE_PROVIDER_CAPABILITIES = new Set([ + "streaming", + "tools", + "reasoning", + "vision", + "prompt-cache", +]); + function formatExpiry(value: string): string { const trimmed = value.trim(); if (trimmed.length === 0) { @@ -48,6 +60,15 @@ function formatExpiry(value: string): string { return trimmed; } +function normalizeProviderCapabilities( + values: readonly string[] | undefined, +): RuntimeClineProviderCapability[] | undefined { + const capabilities = values?.filter((value): value is RuntimeClineProviderCapability => + EDITABLE_PROVIDER_CAPABILITIES.has(value as RuntimeClineProviderCapability), + ); + return capabilities && capabilities.length > 0 ? capabilities : undefined; +} + export function ClineSetupSection({ controller, mcpController, @@ -150,7 +171,10 @@ export function ClineSetupSection({ () => clineProviderOptions.find((option) => option.value === controller.providerId) ?? null, [clineProviderOptions, controller.providerId], ); - const canEditSelectedProvider = controller.providerId.trim().length > 0 && !controller.isOauthProviderSelected; + const canEditSelectedProvider = + controller.providerId.trim().length > 0 && + !controller.isOauthProviderSelected && + (selectedProvider?.custom === true || selectedProvider?.client === "openai-compatible"); const selectedProviderEditInitialValues = useMemo((): ClineProviderDialogInitialValues | null => { if (!canEditSelectedProvider) { return null; @@ -164,8 +188,10 @@ export function ClineSetupSection({ providerId: selectedProvider?.id ?? fallbackProviderId, name: selectedProvider?.name ?? fallbackProviderName, baseUrl: controller.baseUrl.trim() || selectedProvider?.baseUrl?.trim() || "", + modelsSourceUrl: selectedProvider?.modelsSourceUrl?.trim() || "", models: normalizedModelIds, defaultModelId: controller.modelId.trim() || selectedProvider?.defaultModelId?.trim() || "", + capabilities: normalizeProviderCapabilities(selectedProvider?.capabilities), }; }, [ canEditSelectedProvider, From b1bcfc1013b0235d3fd949dbd71bd20b82e39727 Mon Sep 17 00:00:00 2001 From: Arafatkatze Date: Sat, 25 Apr 2026 13:25:26 -0700 Subject: [PATCH 2/6] Preload advanced provider edit settings --- src/cline-sdk/cline-provider-service.ts | 2 ++ src/cline-sdk/sdk-provider-boundary.ts | 18 +++++++--- src/core/api-contract.ts | 2 ++ .../shared/cline-setup-section.test.tsx | 35 +++++++++++++++++++ .../components/shared/cline-setup-section.tsx | 2 ++ 5 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/cline-sdk/cline-provider-service.ts b/src/cline-sdk/cline-provider-service.ts index 00f2d51e9..4fba5dad9 100644 --- a/src/cline-sdk/cline-provider-service.ts +++ b/src/cline-sdk/cline-provider-service.ts @@ -757,6 +757,8 @@ export function createClineProviderService() { env: provider.env, capabilities: provider.capabilities, modelsSourceUrl: provider.modelsSourceUrl?.trim() || null, + headers: provider.headers, + timeoutMs: provider.timeoutMs, })) .sort((left, right) => { if (left.id === "cline") { diff --git a/src/cline-sdk/sdk-provider-boundary.ts b/src/cline-sdk/sdk-provider-boundary.ts index 2539ded85..1846aac28 100644 --- a/src/cline-sdk/sdk-provider-boundary.ts +++ b/src/cline-sdk/sdk-provider-boundary.ts @@ -64,6 +64,8 @@ export interface SdkProviderCatalogItem { capabilities?: string[]; client?: string; modelsSourceUrl?: string; + headers?: Record; + timeoutMs?: number; custom?: boolean; } @@ -341,11 +343,17 @@ export async function completeClineDeviceAuth(input: { export async function listSdkProviderCatalog(): Promise { const localModels = await readModelsRegistry(); - return (await ClineCore.Llms.getAllProviders()).map((provider: SdkProviderCatalogItem) => ({ - ...provider, - custom: Boolean(localModels.providers[provider.id.trim().toLowerCase()]), - modelsSourceUrl: localModels.providers[provider.id.trim().toLowerCase()]?.provider.modelsSourceUrl, - })); + return (await ClineCore.Llms.getAllProviders()).map((provider: SdkProviderCatalogItem) => { + const providerId = provider.id.trim().toLowerCase(); + const providerSettings = providerManager.getProviderSettings(providerId); + return { + ...provider, + custom: Boolean(localModels.providers[providerId]), + modelsSourceUrl: localModels.providers[providerId]?.provider.modelsSourceUrl, + headers: providerSettings?.headers, + timeoutMs: providerSettings?.timeout, + }; + }); } export async function listSdkProviderModels(providerId: string): Promise { diff --git a/src/core/api-contract.ts b/src/core/api-contract.ts index 039fcd30c..104a28dfe 100644 --- a/src/core/api-contract.ts +++ b/src/core/api-contract.ts @@ -677,6 +677,8 @@ export const runtimeClineProviderCatalogItemSchema = z.object({ client: z.string().optional(), capabilities: z.array(z.string()).optional(), modelsSourceUrl: z.string().nullable().optional(), + headers: z.record(z.string(), z.string()).optional(), + timeoutMs: z.number().int().positive().optional(), }); export type RuntimeClineProviderCatalogItem = z.infer; diff --git a/web-ui/src/components/shared/cline-setup-section.test.tsx b/web-ui/src/components/shared/cline-setup-section.test.tsx index 8b422ff90..f77ac16dc 100644 --- a/web-ui/src/components/shared/cline-setup-section.test.tsx +++ b/web-ui/src/components/shared/cline-setup-section.test.tsx @@ -190,4 +190,39 @@ describe("ClineSetupSection", () => { expect(findButtonByText(document.body, "Edit")).toBeInstanceOf(HTMLButtonElement); }); + + it("preloads saved advanced settings in the edit dialog", async () => { + await act(async () => { + root.render( + , + ); + }); + + const editButton = findButtonByText(document.body, "Edit"); + expect(editButton).toBeInstanceOf(HTMLButtonElement); + + await act(async () => { + editButton?.click(); + }); + + expect(document.body.querySelector('input[placeholder="30000"]')?.value).toBe("45123"); + expect(document.body.querySelector('input[placeholder="Header name"]')?.value).toBe( + "X-Test-Header", + ); + expect(document.body.querySelector('input[placeholder="Header value"]')?.value).toBe( + "test-value", + ); + }); }); diff --git a/web-ui/src/components/shared/cline-setup-section.tsx b/web-ui/src/components/shared/cline-setup-section.tsx index 8fa343719..366bf199b 100644 --- a/web-ui/src/components/shared/cline-setup-section.tsx +++ b/web-ui/src/components/shared/cline-setup-section.tsx @@ -191,6 +191,8 @@ export function ClineSetupSection({ modelsSourceUrl: selectedProvider?.modelsSourceUrl?.trim() || "", models: normalizedModelIds, defaultModelId: controller.modelId.trim() || selectedProvider?.defaultModelId?.trim() || "", + timeoutMs: selectedProvider?.timeoutMs ?? null, + headers: selectedProvider?.headers, capabilities: normalizeProviderCapabilities(selectedProvider?.capabilities), }; }, [ From 29fdfc0ae5f883529e28195e642e86d06abb47f3 Mon Sep 17 00:00:00 2001 From: Arafatkatze Date: Mon, 27 Apr 2026 13:14:10 -0700 Subject: [PATCH 3/6] Preserve provider capability overrides --- src/cline-sdk/sdk-provider-boundary.ts | 6 ++-- .../shared/cline-add-provider-dialog.test.tsx | 24 +++++++++++++++ .../shared/cline-add-provider-dialog.tsx | 2 +- .../shared/cline-setup-section.test.tsx | 29 +++++++++++++++++++ .../components/shared/cline-setup-section.tsx | 5 +++- 5 files changed, 62 insertions(+), 4 deletions(-) diff --git a/src/cline-sdk/sdk-provider-boundary.ts b/src/cline-sdk/sdk-provider-boundary.ts index 1846aac28..6cd96d366 100644 --- a/src/cline-sdk/sdk-provider-boundary.ts +++ b/src/cline-sdk/sdk-provider-boundary.ts @@ -345,11 +345,13 @@ export async function listSdkProviderCatalog(): Promise { const providerId = provider.id.trim().toLowerCase(); + const localProvider = localModels.providers[providerId]?.provider; const providerSettings = providerManager.getProviderSettings(providerId); return { ...provider, - custom: Boolean(localModels.providers[providerId]), - modelsSourceUrl: localModels.providers[providerId]?.provider.modelsSourceUrl, + custom: Boolean(localProvider), + capabilities: localProvider?.capabilities ?? provider.capabilities, + modelsSourceUrl: localProvider?.modelsSourceUrl, headers: providerSettings?.headers, timeoutMs: providerSettings?.timeout, }; diff --git a/web-ui/src/components/shared/cline-add-provider-dialog.test.tsx b/web-ui/src/components/shared/cline-add-provider-dialog.test.tsx index fcb802a26..787a4560b 100644 --- a/web-ui/src/components/shared/cline-add-provider-dialog.test.tsx +++ b/web-ui/src/components/shared/cline-add-provider-dialog.test.tsx @@ -200,4 +200,28 @@ describe("ClineAddProviderDialog", () => { }), ); }); + + it("does not replace explicit empty edit capabilities with add-mode defaults", async () => { + await act(async () => { + root.render( + {}} + existingProviderIds={["litellm"]} + mode="edit" + initialValues={{ + providerId: "litellm", + name: "LiteLLM", + baseUrl: "http://localhost:4000/v1", + models: ["gpt-5.4"], + capabilities: [], + }} + onSubmit={async () => ({ ok: true })} + />, + ); + }); + + expect(findButtonByText(document.body, "streaming")?.getAttribute("aria-pressed")).toBe("false"); + expect(findButtonByText(document.body, "tools")?.getAttribute("aria-pressed")).toBe("false"); + }); }); diff --git a/web-ui/src/components/shared/cline-add-provider-dialog.tsx b/web-ui/src/components/shared/cline-add-provider-dialog.tsx index 3c879e459..b1398f51c 100644 --- a/web-ui/src/components/shared/cline-add-provider-dialog.tsx +++ b/web-ui/src/components/shared/cline-add-provider-dialog.tsx @@ -74,7 +74,7 @@ function createInitialFormState(initialValues?: ClineProviderDialogInitialValues defaultModelId: initialValues?.defaultModelId?.trim() || initialModels[0] || "", timeoutMs: initialValues?.timeoutMs ? String(initialValues.timeoutMs) : "", headers: initialHeaders, - capabilities: initialValues?.capabilities?.length ? initialValues.capabilities : ["streaming", "tools"], + capabilities: initialValues?.capabilities !== undefined ? initialValues.capabilities : ["streaming", "tools"], }; } diff --git a/web-ui/src/components/shared/cline-setup-section.test.tsx b/web-ui/src/components/shared/cline-setup-section.test.tsx index f77ac16dc..92bd48221 100644 --- a/web-ui/src/components/shared/cline-setup-section.test.tsx +++ b/web-ui/src/components/shared/cline-setup-section.test.tsx @@ -225,4 +225,33 @@ describe("ClineSetupSection", () => { "test-value", ); }); + + it("preloads saved capability overrides in the edit dialog", async () => { + await act(async () => { + root.render( + , + ); + }); + + const editButton = findButtonByText(document.body, "Edit"); + expect(editButton).toBeInstanceOf(HTMLButtonElement); + + await act(async () => { + editButton?.click(); + }); + + expect(findButtonByText(document.body, "vision")?.getAttribute("aria-pressed")).toBe("true"); + expect(findButtonByText(document.body, "reasoning")?.getAttribute("aria-pressed")).toBe("true"); + expect(findButtonByText(document.body, "streaming")?.getAttribute("aria-pressed")).toBe("false"); + expect(findButtonByText(document.body, "tools")?.getAttribute("aria-pressed")).toBe("false"); + }); }); diff --git a/web-ui/src/components/shared/cline-setup-section.tsx b/web-ui/src/components/shared/cline-setup-section.tsx index 366bf199b..e3d87c8a9 100644 --- a/web-ui/src/components/shared/cline-setup-section.tsx +++ b/web-ui/src/components/shared/cline-setup-section.tsx @@ -63,10 +63,13 @@ function formatExpiry(value: string): string { function normalizeProviderCapabilities( values: readonly string[] | undefined, ): RuntimeClineProviderCapability[] | undefined { + if (values === undefined) { + return undefined; + } const capabilities = values?.filter((value): value is RuntimeClineProviderCapability => EDITABLE_PROVIDER_CAPABILITIES.has(value as RuntimeClineProviderCapability), ); - return capabilities && capabilities.length > 0 ? capabilities : undefined; + return capabilities ?? []; } export function ClineSetupSection({ From 1da31120fb8b647eda9d3f4f2ff6b3b47480528b Mon Sep 17 00:00:00 2001 From: Arafatkatze Date: Mon, 27 Apr 2026 17:20:42 -0700 Subject: [PATCH 4/6] Harden provider override seeding --- src/cline-sdk/sdk-provider-boundary.ts | 33 +++++++++++--------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/src/cline-sdk/sdk-provider-boundary.ts b/src/cline-sdk/sdk-provider-boundary.ts index 6cd96d366..1033cdb28 100644 --- a/src/cline-sdk/sdk-provider-boundary.ts +++ b/src/cline-sdk/sdk-provider-boundary.ts @@ -350,8 +350,8 @@ export async function listSdkProviderCatalog(): Promise 0 ? [...new Set(capabilities)] : undefined; } -function isMissingLocalProviderError(error: unknown, providerId: string): boolean { - const message = error instanceof Error ? error.message : String(error); - return message === `provider "${providerId}" does not exist`; -} - function titleCaseProviderId(providerId: string): string { return providerId .split(/[-_]/) @@ -544,26 +539,26 @@ export async function updateSdkCustomProvider(input: UpdateSdkCustomProviderInpu ).updateLocalProvider; if (updateLocalProvider) { const providerId = normalizeProviderId(input.providerId); + const previousModelsState = await readModelsRegistry(); + const hasLocalProvider = Boolean(previousModelsState.providers[providerId]); + const shouldSeedLocalOverride = !hasLocalProvider && Boolean(await getOverrideableBuiltInProvider(providerId)); + const previousSettingsState = shouldSeedLocalOverride ? providerManager.read() : null; + if (shouldSeedLocalOverride) { + await seedLocalProviderOverride(input); + } try { await updateLocalProvider(providerManager, input); return; } catch (error) { - if (!isMissingLocalProviderError(error, providerId) || !(await getOverrideableBuiltInProvider(providerId))) { - throw error; - } - const previousModelsState = await readModelsRegistry(); - const previousSettingsState = providerManager.read(); - await seedLocalProviderOverride(input); - try { - await updateLocalProvider(providerManager, input); - } catch (updateError) { + if (shouldSeedLocalOverride) { await writeModelsRegistry(previousModelsState); - providerManager.write(previousSettingsState); + if (previousSettingsState) { + providerManager.write(previousSettingsState); + } ClineCore.Llms.unregisterProvider(providerId); - throw updateError; } + throw error; } - return; } const providerId = normalizeProviderId(input.providerId); From d41c28889a7dfaad8eb8a726b429b84af948f11a Mon Sep 17 00:00:00 2001 From: Arafatkatze Date: Mon, 27 Apr 2026 17:23:56 -0700 Subject: [PATCH 5/6] Fix Cline setup test controller mock --- web-ui/src/components/shared/cline-setup-section.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/web-ui/src/components/shared/cline-setup-section.test.tsx b/web-ui/src/components/shared/cline-setup-section.test.tsx index 92bd48221..c96fbbcfb 100644 --- a/web-ui/src/components/shared/cline-setup-section.test.tsx +++ b/web-ui/src/components/shared/cline-setup-section.test.tsx @@ -35,6 +35,9 @@ function createController( overrides: Partial = {}, ): UseRuntimeSettingsClineControllerResult { const providerId = provider.id; + const currentMainControllerMethods = { + refreshProviderModels: vi.fn(async () => ({ ok: true })), + }; return { currentProviderSettings: { providerId, @@ -94,6 +97,7 @@ function createController( selectedModelSupportsReasoningEffort: false, hasUnsavedChanges: false, saveProviderSettings: vi.fn(async () => ({ ok: true })), + ...currentMainControllerMethods, addCustomProvider: vi.fn(async () => ({ ok: true })), updateCustomProvider: vi.fn(async () => ({ ok: true })), runOauthLogin: vi.fn(async () => ({ ok: true })), From 542707796da0d8b358dd1b0da84f100da8223b0f Mon Sep 17 00:00:00 2001 From: Arafatkatze Date: Mon, 27 Apr 2026 17:28:50 -0700 Subject: [PATCH 6/6] Wrap Cline setup tests with tooltip provider --- .../shared/cline-setup-section.test.tsx | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/web-ui/src/components/shared/cline-setup-section.test.tsx b/web-ui/src/components/shared/cline-setup-section.test.tsx index c96fbbcfb..d7fa01b89 100644 --- a/web-ui/src/components/shared/cline-setup-section.test.tsx +++ b/web-ui/src/components/shared/cline-setup-section.test.tsx @@ -1,8 +1,9 @@ -import { act } from "react"; +import { act, type ComponentProps, type ReactElement } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ClineSetupSection } from "@/components/shared/cline-setup-section"; +import { TooltipProvider } from "@/components/ui/tooltip"; import type { UseRuntimeSettingsClineControllerResult } from "@/hooks/use-runtime-settings-cline-controller"; import type { RuntimeClineProviderCatalogItem } from "@/runtime/types"; @@ -15,6 +16,14 @@ function findButtonByText(container: ParentNode, text: string): HTMLButtonElemen null) as HTMLButtonElement | null; } +function ClineSetupSectionTestHarness(props: ComponentProps): ReactElement { + return ( + + + + ); +} + function createProvider(overrides: Partial = {}): RuntimeClineProviderCatalogItem { return { id: "litellm", @@ -136,7 +145,7 @@ describe("ClineSetupSection", () => { it("shows edit controls for OpenAI-compatible built-in providers", async () => { await act(async () => { root.render( - { it("does not show edit controls for non-OpenAI-compatible built-in providers", async () => { await act(async () => { root.render( - { it("does not show edit controls for managed OAuth providers", async () => { await act(async () => { root.render( - { it("shows custom-provider edit controls for local custom providers", async () => { await act(async () => { root.render( - { it("preloads saved advanced settings in the edit dialog", async () => { await act(async () => { root.render( - { it("preloads saved capability overrides in the edit dialog", async () => { await act(async () => { root.render( -