feat: modularize provider plugin architecture

This commit is contained in:
Peter Steinberger
2026-03-12 22:24:22 +00:00
parent bf89947a8e
commit d83491e751
41 changed files with 1734 additions and 260 deletions

View File

@@ -25,8 +25,11 @@ export type NormalizedPluginsConfig = {
export const BUNDLED_ENABLED_BY_DEFAULT = new Set<string>([
"device-pair",
"ollama",
"phone-control",
"sglang",
"talk-voice",
"vllm",
]);
const normalizeList = (value: unknown): string[] => {

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import type { ModelProviderConfig } from "../config/types.js";
import {
groupPluginDiscoveryProvidersByOrder,
normalizePluginDiscoveryResult,
} from "./provider-discovery.js";
import type { ProviderDiscoveryOrder, ProviderPlugin } from "./types.js";
function makeProvider(params: {
id: string;
label?: string;
order?: ProviderDiscoveryOrder;
}): ProviderPlugin {
return {
id: params.id,
label: params.label ?? params.id,
auth: [],
discovery: {
...(params.order ? { order: params.order } : {}),
run: async () => null,
},
};
}
function makeModelProviderConfig(overrides?: Partial<ModelProviderConfig>): ModelProviderConfig {
return {
baseUrl: "http://127.0.0.1:8000/v1",
models: [],
...overrides,
};
}
describe("groupPluginDiscoveryProvidersByOrder", () => {
it("groups providers by declared order and sorts labels within each group", () => {
const grouped = groupPluginDiscoveryProvidersByOrder([
makeProvider({ id: "late-b", label: "Zulu" }),
makeProvider({ id: "late-a", label: "Alpha" }),
makeProvider({ id: "paired", label: "Paired", order: "paired" }),
makeProvider({ id: "profile", label: "Profile", order: "profile" }),
makeProvider({ id: "simple", label: "Simple", order: "simple" }),
]);
expect(grouped.simple.map((provider) => provider.id)).toEqual(["simple"]);
expect(grouped.profile.map((provider) => provider.id)).toEqual(["profile"]);
expect(grouped.paired.map((provider) => provider.id)).toEqual(["paired"]);
expect(grouped.late.map((provider) => provider.id)).toEqual(["late-a", "late-b"]);
});
});
describe("normalizePluginDiscoveryResult", () => {
it("maps a single provider result to the plugin id", () => {
const provider = makeProvider({ id: "Ollama" });
const normalized = normalizePluginDiscoveryResult({
provider,
result: {
provider: makeModelProviderConfig({
baseUrl: "http://127.0.0.1:11434",
api: "ollama",
}),
},
});
expect(normalized).toEqual({
ollama: {
baseUrl: "http://127.0.0.1:11434",
api: "ollama",
models: [],
},
});
});
it("normalizes keys for multi-provider discovery results", () => {
const normalized = normalizePluginDiscoveryResult({
provider: makeProvider({ id: "ignored" }),
result: {
providers: {
" VLLM ": makeModelProviderConfig(),
"": makeModelProviderConfig({ baseUrl: "http://ignored" }),
},
},
});
expect(normalized).toEqual({
vllm: {
baseUrl: "http://127.0.0.1:8000/v1",
models: [],
},
});
});
});

View File

@@ -0,0 +1,65 @@
import { normalizeProviderId } from "../agents/model-selection.js";
import type { OpenClawConfig } from "../config/config.js";
import type { ModelProviderConfig } from "../config/types.js";
import { resolvePluginProviders } from "./providers.js";
import type { ProviderDiscoveryOrder, ProviderPlugin } from "./types.js";
const DISCOVERY_ORDER: readonly ProviderDiscoveryOrder[] = ["simple", "profile", "paired", "late"];
export function resolvePluginDiscoveryProviders(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): ProviderPlugin[] {
return resolvePluginProviders(params).filter((provider) => provider.discovery);
}
export function groupPluginDiscoveryProvidersByOrder(
providers: ProviderPlugin[],
): Record<ProviderDiscoveryOrder, ProviderPlugin[]> {
const grouped = {
simple: [],
profile: [],
paired: [],
late: [],
} as Record<ProviderDiscoveryOrder, ProviderPlugin[]>;
for (const provider of providers) {
const order = provider.discovery?.order ?? "late";
grouped[order].push(provider);
}
for (const order of DISCOVERY_ORDER) {
grouped[order].sort((a, b) => a.label.localeCompare(b.label));
}
return grouped;
}
export function normalizePluginDiscoveryResult(params: {
provider: ProviderPlugin;
result:
| { provider: ModelProviderConfig }
| { providers: Record<string, ModelProviderConfig> }
| null
| undefined;
}): Record<string, ModelProviderConfig> {
const result = params.result;
if (!result) {
return {};
}
if ("provider" in result) {
return { [normalizeProviderId(params.provider.id)]: result.provider };
}
const normalized: Record<string, ModelProviderConfig> = {};
for (const [key, value] of Object.entries(result.providers)) {
const normalizedKey = normalizeProviderId(key);
if (!normalizedKey || !value) {
continue;
}
normalized[normalizedKey] = value;
}
return normalized;
}

View File

@@ -0,0 +1,243 @@
import { DEFAULT_PROVIDER } from "../agents/defaults.js";
import { parseModelRef } from "../agents/model-selection.js";
import { normalizeProviderId } from "../agents/model-selection.js";
import type { OpenClawConfig } from "../config/config.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { resolvePluginProviders } from "./providers.js";
import type {
ProviderAuthMethod,
ProviderPlugin,
ProviderPluginWizardModelPicker,
ProviderPluginWizardOnboarding,
} from "./types.js";
export const PROVIDER_PLUGIN_CHOICE_PREFIX = "provider-plugin:";
export type ProviderWizardOption = {
value: string;
label: string;
hint?: string;
groupId: string;
groupLabel: string;
groupHint?: string;
};
export type ProviderModelPickerEntry = {
value: string;
label: string;
hint?: string;
};
function normalizeChoiceId(choiceId: string): string {
return choiceId.trim();
}
function resolveWizardOnboardingChoiceId(
provider: ProviderPlugin,
wizard: ProviderPluginWizardOnboarding,
): string {
const explicit = wizard.choiceId?.trim();
if (explicit) {
return explicit;
}
const explicitMethodId = wizard.methodId?.trim();
if (explicitMethodId) {
return buildProviderPluginMethodChoice(provider.id, explicitMethodId);
}
if (provider.auth.length === 1) {
return provider.id;
}
return buildProviderPluginMethodChoice(provider.id, provider.auth[0]?.id ?? "default");
}
function resolveMethodById(
provider: ProviderPlugin,
methodId?: string,
): ProviderAuthMethod | undefined {
const normalizedMethodId = methodId?.trim().toLowerCase();
if (!normalizedMethodId) {
return provider.auth[0];
}
return provider.auth.find((method) => method.id.trim().toLowerCase() === normalizedMethodId);
}
function buildOnboardingOptionForMethod(params: {
provider: ProviderPlugin;
wizard: ProviderPluginWizardOnboarding;
method: ProviderAuthMethod;
value: string;
}): ProviderWizardOption {
const normalizedGroupId = params.wizard.groupId?.trim() || params.provider.id;
return {
value: normalizeChoiceId(params.value),
label:
params.wizard.choiceLabel?.trim() ||
(params.provider.auth.length === 1 ? params.provider.label : params.method.label),
hint: params.wizard.choiceHint?.trim() || params.method.hint,
groupId: normalizedGroupId,
groupLabel: params.wizard.groupLabel?.trim() || params.provider.label,
groupHint: params.wizard.groupHint?.trim(),
};
}
export function buildProviderPluginMethodChoice(providerId: string, methodId: string): string {
return `${PROVIDER_PLUGIN_CHOICE_PREFIX}${providerId.trim()}:${methodId.trim()}`;
}
export function resolveProviderWizardOptions(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): ProviderWizardOption[] {
const providers = resolvePluginProviders(params);
const options: ProviderWizardOption[] = [];
for (const provider of providers) {
const wizard = provider.wizard?.onboarding;
if (!wizard) {
continue;
}
const explicitMethod = resolveMethodById(provider, wizard.methodId);
if (explicitMethod) {
options.push(
buildOnboardingOptionForMethod({
provider,
wizard,
method: explicitMethod,
value: resolveWizardOnboardingChoiceId(provider, wizard),
}),
);
continue;
}
for (const method of provider.auth) {
options.push(
buildOnboardingOptionForMethod({
provider,
wizard,
method,
value: buildProviderPluginMethodChoice(provider.id, method.id),
}),
);
}
}
return options;
}
function resolveModelPickerChoiceValue(
provider: ProviderPlugin,
modelPicker: ProviderPluginWizardModelPicker,
): string {
const explicitMethodId = modelPicker.methodId?.trim();
if (explicitMethodId) {
return buildProviderPluginMethodChoice(provider.id, explicitMethodId);
}
if (provider.auth.length === 1) {
return provider.id;
}
return buildProviderPluginMethodChoice(provider.id, provider.auth[0]?.id ?? "default");
}
export function resolveProviderModelPickerEntries(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): ProviderModelPickerEntry[] {
const providers = resolvePluginProviders(params);
const entries: ProviderModelPickerEntry[] = [];
for (const provider of providers) {
const modelPicker = provider.wizard?.modelPicker;
if (!modelPicker) {
continue;
}
entries.push({
value: resolveModelPickerChoiceValue(provider, modelPicker),
label: modelPicker.label?.trim() || `${provider.label} (custom)`,
hint: modelPicker.hint?.trim(),
});
}
return entries;
}
export function resolveProviderPluginChoice(params: {
providers: ProviderPlugin[];
choice: string;
}): { provider: ProviderPlugin; method: ProviderAuthMethod } | null {
const choice = params.choice.trim();
if (!choice) {
return null;
}
if (choice.startsWith(PROVIDER_PLUGIN_CHOICE_PREFIX)) {
const payload = choice.slice(PROVIDER_PLUGIN_CHOICE_PREFIX.length);
const separator = payload.indexOf(":");
const providerId = separator >= 0 ? payload.slice(0, separator) : payload;
const methodId = separator >= 0 ? payload.slice(separator + 1) : undefined;
const provider = params.providers.find(
(entry) => normalizeProviderId(entry.id) === normalizeProviderId(providerId),
);
if (!provider) {
return null;
}
const method = resolveMethodById(provider, methodId);
return method ? { provider, method } : null;
}
for (const provider of params.providers) {
const onboarding = provider.wizard?.onboarding;
if (onboarding) {
const onboardingChoiceId = resolveWizardOnboardingChoiceId(provider, onboarding);
if (normalizeChoiceId(onboardingChoiceId) === choice) {
const method = resolveMethodById(provider, onboarding.methodId);
if (method) {
return { provider, method };
}
}
}
if (
normalizeProviderId(provider.id) === normalizeProviderId(choice) &&
provider.auth.length > 0
) {
return { provider, method: provider.auth[0] };
}
}
return null;
}
export async function runProviderModelSelectedHook(params: {
config: OpenClawConfig;
model: string;
prompter: WizardPrompter;
agentDir?: string;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): Promise<void> {
const parsed = parseModelRef(params.model, DEFAULT_PROVIDER);
if (!parsed) {
return;
}
const providers = resolvePluginProviders({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
});
const provider = providers.find(
(entry) => normalizeProviderId(entry.id) === normalizeProviderId(parsed.provider),
);
if (!provider?.onModelSelected) {
return;
}
await provider.onModelSelected({
config: params.config,
model: params.model,
prompter: params.prompter,
agentDir: params.agentDir,
workspaceDir: params.workspaceDir,
});
}

View File

@@ -119,6 +119,59 @@ export type ProviderAuthMethod = {
run: (ctx: ProviderAuthContext) => Promise<ProviderAuthResult>;
};
export type ProviderDiscoveryOrder = "simple" | "profile" | "paired" | "late";
export type ProviderDiscoveryContext = {
config: OpenClawConfig;
agentDir?: string;
workspaceDir?: string;
env: NodeJS.ProcessEnv;
resolveProviderApiKey: (providerId?: string) => {
apiKey: string | undefined;
discoveryApiKey?: string;
};
};
export type ProviderDiscoveryResult =
| { provider: ModelProviderConfig }
| { providers: Record<string, ModelProviderConfig> }
| null
| undefined;
export type ProviderPluginDiscovery = {
order?: ProviderDiscoveryOrder;
run: (ctx: ProviderDiscoveryContext) => Promise<ProviderDiscoveryResult>;
};
export type ProviderPluginWizardOnboarding = {
choiceId?: string;
choiceLabel?: string;
choiceHint?: string;
groupId?: string;
groupLabel?: string;
groupHint?: string;
methodId?: string;
};
export type ProviderPluginWizardModelPicker = {
label?: string;
hint?: string;
methodId?: string;
};
export type ProviderPluginWizard = {
onboarding?: ProviderPluginWizardOnboarding;
modelPicker?: ProviderPluginWizardModelPicker;
};
export type ProviderModelSelectedContext = {
config: OpenClawConfig;
model: string;
prompter: WizardPrompter;
agentDir?: string;
workspaceDir?: string;
};
export type ProviderPlugin = {
id: string;
label: string;
@@ -127,8 +180,11 @@ export type ProviderPlugin = {
envVars?: string[];
models?: ModelProviderConfig;
auth: ProviderAuthMethod[];
discovery?: ProviderPluginDiscovery;
wizard?: ProviderPluginWizard;
formatApiKey?: (cred: AuthProfileCredential) => string;
refreshOAuth?: (cred: OAuthCredential) => Promise<OAuthCredential>;
onModelSelected?: (ctx: ProviderModelSelectedContext) => Promise<void>;
};
export type OpenClawPluginGatewayMethod = {