feat(providers): remove custom providers from settings

Add a destructive removal flow so mistaken custom providers can be deleted from config.json instead of only disabled.
This commit is contained in:
Mohamed Boudra
2026-07-16 15:10:52 +02:00
parent 6aba0370ae
commit 8b8f35fc37
20 changed files with 654 additions and 63 deletions
@@ -105,6 +105,7 @@ export interface ProviderSnapshotEntry {
provider: AgentProvider;
status: ProviderStatus;
enabled: boolean;
source?: "builtin" | "custom";
error?: string;
models?: AgentModelDefinition[];
modes?: AgentMode[];
@@ -952,6 +952,9 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => {
expect(manager.hasProvider("zai-claude")).toBe(true);
expect(state.providerDefinitions["zai-claude"]).toMatchObject({ enabled: true });
expect(manager.listRegisteredProviderIds()).toContain("zai-claude");
expect(manager.getSnapshot().find((entry) => entry.provider === "zai-claude")?.source).toBe(
"custom",
);
} finally {
manager.destroy();
}
@@ -27,6 +27,7 @@ import {
shutdownAgentClients,
type ProviderDefinition,
} from "./provider-registry.js";
import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest";
import { applyMutableProviderConfigToOverrides } from "../daemon-config-store.js";
import {
formatProviderDiagnostic,
@@ -503,6 +504,7 @@ export class ProviderSnapshotManager {
provider,
status: "error",
enabled: definition.enabled,
source: this.getProviderSource(provider),
label: definition.label,
description: definition.description,
defaultModeId: definition.defaultModeId,
@@ -536,6 +538,11 @@ export class ProviderSnapshotManager {
}
}
private getProviderSource(provider: AgentProvider): ProviderSnapshotEntry["source"] {
const isBuiltin = BUILTIN_PROVIDER_IDS.includes(provider);
return !isBuiltin && this.providerOverrides?.[provider]?.extends ? "custom" : "builtin";
}
private createLoadingEntries(): Map<AgentProvider, ProviderSnapshotEntry> {
const entries = new Map<AgentProvider, ProviderSnapshotEntry>();
for (const provider of this.getProviderIds()) {
@@ -544,6 +551,7 @@ export class ProviderSnapshotManager {
provider,
status: "loading",
enabled: definition?.enabled ?? true,
source: this.getProviderSource(provider),
label: definition?.label,
description: definition?.description,
defaultModeId: definition?.defaultModeId ?? null,
@@ -562,6 +570,7 @@ export class ProviderSnapshotManager {
const metadata = {
provider,
enabled: definition?.enabled ?? true,
source: this.getProviderSource(provider),
label: definition?.label,
description: definition?.description,
defaultModeId: definition?.defaultModeId ?? null,
@@ -725,6 +734,7 @@ export class ProviderSnapshotManager {
const snapshot = this.getOrCreateSnapshot(snapshotCwd);
const base = {
provider,
source: this.getProviderSource(provider),
label: definition.label,
description: definition.description,
defaultModeId: definition.defaultModeId,
@@ -119,6 +119,105 @@ describe("DaemonConfigStore", () => {
});
});
test("patch removes provider entries from config.json", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const configPath = path.join(paseoHome, "config.json");
writeFileSync(
configPath,
`${JSON.stringify(
{
version: 1,
agents: {
providers: {
gemini: {
extends: "acp",
label: "Gemini",
command: ["gemini", "--acp"],
},
claude: {
enabled: false,
},
},
},
},
null,
2,
)}\n`,
);
const store = new DaemonConfigStore(
paseoHome,
{
mcp: { injectIntoAgents: false },
browserTools: { enabled: false },
providers: {
gemini: {},
claude: { enabled: false },
},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
},
undefined,
);
const next = store.patch({ removeProviders: ["gemini"] });
expect(next.providers.gemini).toBeUndefined();
expect(next.providers.claude).toEqual({ enabled: false });
const persisted = loadPersistedConfig(paseoHome);
expect(persisted.agents?.providers?.gemini).toBeUndefined();
expect(persisted.agents?.providers?.claude).toEqual({ enabled: false });
});
test("patch removes the providers object when the last provider is deleted", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const configPath = path.join(paseoHome, "config.json");
writeFileSync(
configPath,
`${JSON.stringify(
{
version: 1,
agents: {
providers: {
gemini: {
extends: "acp",
label: "Gemini",
command: ["gemini", "--acp"],
},
},
},
},
null,
2,
)}\n`,
);
const store = new DaemonConfigStore(
paseoHome,
{
mcp: { injectIntoAgents: false },
browserTools: { enabled: false },
providers: { gemini: {} },
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
},
undefined,
);
store.patch({ removeProviders: ["gemini"] });
const persisted = loadPersistedConfig(paseoHome);
expect(persisted.agents?.providers).toBeUndefined();
});
test("patch persists append system prompt into config.json", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -52,6 +52,53 @@ function deepMerge<T extends Record<string, unknown>>(
return next as T;
}
function omitProvidersFromConfig<T extends { providers?: Record<string, unknown> }>(
config: T,
providers: readonly string[],
): T {
if (providers.length === 0 || !config.providers) {
return config;
}
let changed = false;
const nextProviders = { ...config.providers };
for (const provider of providers) {
if (provider in nextProviders) {
delete nextProviders[provider];
changed = true;
}
}
return changed ? ({ ...config, providers: nextProviders } as T) : config;
}
function omitProvidersFromOverrides(
overrides: Record<string, ProviderOverride> | undefined,
providers: readonly string[],
): Record<string, ProviderOverride> | undefined {
if (!overrides) {
return undefined;
}
const nextOverrides = { ...overrides };
for (const provider of providers) {
delete nextOverrides[provider];
}
return Object.keys(nextOverrides).length > 0 ? nextOverrides : undefined;
}
function omitProvidersFromPersistedAgents(
agents: PersistedConfig["agents"],
): Record<string, unknown> | undefined {
if (!agents) {
return undefined;
}
const { providers: _providers, ...rest } = agents as Record<string, unknown>;
return Object.keys(rest).length > 0 ? rest : undefined;
}
function getValueAtPath(config: MutableDaemonConfig, path: string): unknown {
return path
.split(".")
@@ -100,7 +147,9 @@ export class DaemonConfigStore {
public patch(partial: MutableDaemonConfigPatch): MutableDaemonConfig {
const parsedPatch = MutableDaemonConfigPatchSchema.parse(partial);
const next = MutableDaemonConfigSchema.parse(deepMerge(this.current, parsedPatch));
const { removeProviders = [], ...configPatch } = parsedPatch;
const merged = deepMerge(this.current, configPatch);
const next = MutableDaemonConfigSchema.parse(omitProvidersFromConfig(merged, removeProviders));
const changedFieldPaths = Array.from(this.fieldChangeHandlers.keys()).filter((path) => {
return !isEqualValue(getValueAtPath(this.current, path), getValueAtPath(next, path));
@@ -112,7 +161,7 @@ export class DaemonConfigStore {
// Persist before updating in-memory state so that if persistence fails,
// runtime and disk stay consistent.
this.persistConfig(next);
this.persistConfig(next, removeProviders);
this.current = next;
for (const path of changedFieldPaths) {
@@ -157,11 +206,12 @@ export class DaemonConfigStore {
};
}
private persistConfig(config: MutableDaemonConfig): void {
private persistConfig(config: MutableDaemonConfig, removeProviders: readonly string[]): void {
const persisted = loadPersistedConfig(this.paseoHome, this.logger);
const nextPersisted = mergeMutableConfigIntoPersistedConfig({
persisted,
mutable: config,
removeProviders,
});
savePersistedConfig(this.paseoHome, nextPersisted, this.logger);
}
@@ -170,22 +220,27 @@ export class DaemonConfigStore {
function mergeMutableConfigIntoPersistedConfig(params: {
persisted: PersistedConfig;
mutable: MutableDaemonConfig;
removeProviders: readonly string[];
}): PersistedConfig {
const { persisted, mutable } = params;
const { persisted, mutable, removeProviders } = params;
const browserToolsEnabled = readBrowserToolsEnabled(mutable);
const metadataGenerationProviders = readMetadataGenerationProviders(mutable);
const providerOverrides = applyMutableProviderConfigToOverrides(
const persistedProviderOverrides = omitProvidersFromOverrides(
persisted.agents?.providers as Record<string, ProviderOverride> | undefined,
removeProviders,
);
const providerOverrides = applyMutableProviderConfigToOverrides(
persistedProviderOverrides,
mutable.providers,
);
const persistedAgents = persisted.agents as Record<string, unknown> | undefined;
const persistedAgents = omitProvidersFromPersistedAgents(persisted.agents);
const persistedMetadataGeneration = {
providers: metadataGenerationProviders,
};
const shouldPersistMetadataGeneration =
metadataGenerationProviders.length > 0 || persisted.agents?.metadataGeneration !== undefined;
let nextAgents = persisted.agents as PersistedConfig["agents"];
let nextAgents = persistedAgents as PersistedConfig["agents"];
if (providerOverrides && Object.keys(providerOverrides).length > 0) {
nextAgents = {
...persistedAgents,
@@ -1252,6 +1252,8 @@ export class VoiceAssistantWebSocketServer {
workspaceGithubRepositorySearch: true,
// COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15.
projectCreateDirectory: true,
// COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105.
providerRemoval: true,
},
};
}