mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-03 17:50:22 +00:00
feat: modularize provider plugin architecture
This commit is contained in:
@@ -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[] => {
|
||||
|
||||
90
src/plugins/provider-discovery.test.ts
Normal file
90
src/plugins/provider-discovery.test.ts
Normal 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: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
65
src/plugins/provider-discovery.ts
Normal file
65
src/plugins/provider-discovery.ts
Normal 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;
|
||||
}
|
||||
243
src/plugins/provider-wizard.ts
Normal file
243
src/plugins/provider-wizard.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user