Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/cline-sdk/cline-provider-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -747,12 +747,18 @@ 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,
headers: provider.headers,
timeoutMs: provider.timeoutMs,
}))
.sort((left, right) => {
if (left.id === "cline") {
Expand Down
219 changes: 197 additions & 22 deletions src/cline-sdk/sdk-provider-boundary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -62,6 +62,11 @@ export interface SdkProviderCatalogItem {
baseUrl?: string;
env?: string[];
capabilities?: string[];
client?: string;
modelsSourceUrl?: string;
headers?: Record<string, string>;
timeoutMs?: number;
custom?: boolean;
}

export interface SdkProviderModel {
Expand Down Expand Up @@ -125,11 +130,43 @@ type LocalModelsFile = {
capabilities?: SdkCustomProviderCapability[];
modelsSourceUrl?: string;
};
models: Record<string, { id: string; name: string }>;
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<string, string> | null;
timeoutMs?: number | null;
models?: string[];
defaultModelId?: string | null;
modelsSourceUrl?: string | null;
capabilities?: SdkCustomProviderCapability[];
};

const MANAGED_OAUTH_PROVIDER_IDS = new Set<string>(["cline", "oca", "openai-codex"]);
const OPENAI_COMPATIBLE_CLIENT = "openai-compatible";
const CUSTOM_PROVIDER_CAPABILITIES = new Set<SdkCustomProviderCapability>([
"streaming",
"tools",
"reasoning",
"vision",
"prompt-cache",
]);

export type SdkMcpTool = Tool;

export interface SdkMcpServerRegistration {
Expand Down Expand Up @@ -305,7 +342,20 @@ export async function completeClineDeviceAuth(input: {
}

export async function listSdkProviderCatalog(): Promise<SdkProviderCatalogItem[]> {
return await ClineCore.Llms.getAllProviders();
const localModels = await readModelsRegistry();
return (await ClineCore.Llms.getAllProviders()).map((provider: SdkProviderCatalogItem) => {
const providerId = provider.id.trim().toLowerCase();
const localProvider = localModels.providers[providerId]?.provider;
const providerSettings = providerManager.getProviderSettings(providerId);
return {
...provider,
custom: Boolean(localProvider),
capabilities: localProvider ? localProvider.capabilities : provider.capabilities,
modelsSourceUrl: localProvider ? localProvider.modelsSourceUrl : provider.modelsSourceUrl,
headers: providerSettings?.headers,
timeoutMs: providerSettings?.timeout,
};
});
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

export async function listSdkProviderModels(providerId: string): Promise<SdkProviderModel[]> {
Expand Down Expand Up @@ -340,7 +390,126 @@ async function readModelsRegistry(): Promise<LocalModelsFile> {
}

async function writeModelsRegistry(state: LocalModelsFile): Promise<void> {
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 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<SdkProviderCatalogItem | null> {
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<Record<string, { id?: string; name?: string }>> {
return (await ClineCore.Llms.getModelsForProvider(providerId)) as Record<string, { id?: string; name?: string }>;
}

async function resolveSeedModelIds(input: UpdateSdkCustomProviderInput, providerId: string): Promise<string[]> {
const explicitModels = uniqueTrimmed(input.models);
if (explicitModels.length > 0) {
return explicitModels;
}

const registeredModels = await readRegisteredProviderModels(providerId).catch(
(): Record<string, { id?: string; name?: string }> => ({}),
);
const registeredModelIds = uniqueTrimmed(Object.keys(registeredModels));
if (registeredModelIds.length > 0) {
return registeredModelIds;
}

return [];
}

async function seedLocalProviderOverride(input: UpdateSdkCustomProviderInput): Promise<void> {
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<string, { id?: string; name?: string }> => ({}),
);
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<void> {
Expand All @@ -364,27 +533,35 @@ export async function updateSdkCustomProvider(input: UpdateSdkCustomProviderInpu
ClineCore as {
updateLocalProvider?: (
manager: ProviderSettingsManager,
request: {
providerId: string;
name?: string;
baseUrl?: string;
apiKey?: string | null;
headers?: Record<string, string> | null;
timeoutMs?: number | null;
models?: string[];
defaultModelId?: string | null;
modelsSourceUrl?: string | null;
capabilities?: SdkCustomProviderCapability[];
},
request: UpdateLocalProviderRequest,
) => Promise<unknown>;
}
).updateLocalProvider;
if (updateLocalProvider) {
await updateLocalProvider(providerManager, input);
return;
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 (shouldSeedLocalOverride) {
await writeModelsRegistry(previousModelsState);
if (previousSettingsState) {
providerManager.write(previousSettingsState);
}
ClineCore.Llms.unregisterProvider(providerId);
}
throw error;
}
}

const providerId = input.providerId.trim().toLowerCase();
const providerId = normalizeProviderId(input.providerId);
const state = await readModelsRegistry();
const existing = state.providers[providerId];
if (!existing) {
Expand All @@ -396,9 +573,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");
}
Expand Down
6 changes: 6 additions & 0 deletions src/core/api-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,12 @@ 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(),
headers: z.record(z.string(), z.string()).optional(),
timeoutMs: z.number().int().positive().optional(),
});
export type RuntimeClineProviderCatalogItem = z.infer<typeof runtimeClineProviderCatalogItemSchema>;

Expand Down
24 changes: 24 additions & 0 deletions web-ui/src/components/shared/cline-add-provider-dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,4 +200,28 @@ describe("ClineAddProviderDialog", () => {
}),
);
});

it("does not replace explicit empty edit capabilities with add-mode defaults", async () => {
await act(async () => {
root.render(
<ClineAddProviderDialog
open={true}
onOpenChange={() => {}}
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");
});
});
2 changes: 1 addition & 1 deletion web-ui/src/components/shared/cline-add-provider-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
};
}

Expand Down
Loading
Loading