diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index a7fc6a0179f..7dd6a045c15 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -771,18 +771,22 @@ are not just "OAuth helpers" anymore. ### Provider plugin lifecycle -A provider plugin can participate in four distinct phases: +A provider plugin can participate in five distinct phases: 1. **Auth** `auth[].run(ctx)` performs OAuth, API-key capture, device code, or custom setup and returns auth profiles plus optional config patches. -2. **Wizard integration** +2. **Non-interactive setup** + `auth[].runNonInteractive(ctx)` handles `openclaw onboard --non-interactive` + without prompts. Use this when the provider needs custom headless setup + beyond the built-in simple API-key paths. +3. **Wizard integration** `wizard.onboarding` adds an entry to `openclaw onboard`. `wizard.modelPicker` adds a setup entry to the model picker. -3. **Implicit discovery** +4. **Implicit discovery** `discovery.run(ctx)` can contribute provider config automatically during model resolution/listing. -4. **Post-selection follow-up** +5. **Post-selection follow-up** `onModelSelected(ctx)` runs after a model is chosen. Use this for provider- specific work such as downloading a local model. @@ -790,6 +794,7 @@ This is the recommended split because these phases have different lifecycle requirements: - auth is interactive and writes credentials/config +- non-interactive setup is flag/env-driven and must not prompt - wizard metadata is static and UI-facing - discovery should be safe, quick, and failure-tolerant - post-select hooks are side effects tied to the chosen model @@ -814,6 +819,32 @@ Core then: That means a provider plugin owns the provider-specific setup logic, while core owns the generic persistence and config-merge path. +### Provider non-interactive contract + +`auth[].runNonInteractive(ctx)` is optional. Implement it when the provider +needs headless setup that cannot be expressed through the built-in generic +API-key flows. + +The non-interactive context includes: + +- the current and base config +- parsed onboarding CLI options +- runtime logging/error helpers +- agent/workspace dirs +- `resolveApiKey(...)` to read provider keys from flags, env, or existing auth + profiles while honoring `--secret-input-mode` +- `toApiKeyCredential(...)` to convert a resolved key into an auth-profile + credential with the right plaintext vs secret-ref storage + +Use this surface for providers such as: + +- self-hosted OpenAI-compatible runtimes that need `--custom-base-url` + + `--custom-model-id` +- provider-specific non-interactive verification or config synthesis + +Do not prompt from `runNonInteractive`. Reject missing inputs with actionable +errors instead. + ### Provider wizard metadata `wizard.onboarding` controls how the provider appears in grouped onboarding: @@ -836,6 +867,13 @@ entry in model selection: When a provider has multiple auth methods, the wizard can either point at one explicit method or let OpenClaw synthesize per-method choices. +OpenClaw validates provider wizard metadata when the plugin registers: + +- duplicate or blank auth-method ids are rejected +- wizard metadata is ignored when the provider has no auth methods +- invalid `methodId` bindings are downgraded to warnings and fall back to the + provider's remaining auth methods + ### Provider discovery contract `discovery.run(ctx)` returns one of: @@ -970,6 +1008,9 @@ Notes: - `run` receives a `ProviderAuthContext` with `prompter`, `runtime`, `openUrl`, and `oauth.createVpsAwareHandlers` helpers. +- `runNonInteractive` receives a `ProviderAuthMethodNonInteractiveContext` + with `opts`, `resolveApiKey`, and `toApiKeyCredential` helpers for + headless onboarding. - Return `configPatch` when you need to add default models or provider config. - Return `defaultModel` so `--set-default` can update agent defaults. - `wizard.onboarding` adds a provider choice to `openclaw onboard`. diff --git a/src/plugins/provider-validation.test.ts b/src/plugins/provider-validation.test.ts new file mode 100644 index 00000000000..e37f1d38163 --- /dev/null +++ b/src/plugins/provider-validation.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import { normalizeRegisteredProvider } from "./provider-validation.js"; +import type { PluginDiagnostic, ProviderPlugin } from "./types.js"; + +function collectDiagnostics() { + const diagnostics: PluginDiagnostic[] = []; + return { + diagnostics, + pushDiagnostic: (diag: PluginDiagnostic) => { + diagnostics.push(diag); + }, + }; +} + +function makeProvider(overrides: Partial): ProviderPlugin { + return { + id: "demo", + label: "Demo", + auth: [], + ...overrides, + }; +} + +describe("normalizeRegisteredProvider", () => { + it("drops invalid and duplicate auth methods, and clears bad wizard method bindings", () => { + const { diagnostics, pushDiagnostic } = collectDiagnostics(); + + const provider = normalizeRegisteredProvider({ + pluginId: "demo-plugin", + source: "/tmp/demo/index.ts", + provider: makeProvider({ + id: " demo ", + label: " Demo Provider ", + aliases: [" alias-one ", "alias-one", ""], + envVars: [" DEMO_API_KEY ", "DEMO_API_KEY"], + auth: [ + { + id: " primary ", + label: " Primary ", + kind: "custom", + run: async () => ({ profiles: [] }), + }, + { + id: "primary", + label: "Duplicate", + kind: "custom", + run: async () => ({ profiles: [] }), + }, + { id: " ", label: "Missing", kind: "custom", run: async () => ({ profiles: [] }) }, + ], + wizard: { + onboarding: { + choiceId: " demo-choice ", + methodId: " missing ", + }, + modelPicker: { + label: " Demo models ", + methodId: " missing ", + }, + }, + }), + pushDiagnostic, + }); + + expect(provider).toMatchObject({ + id: "demo", + label: "Demo Provider", + aliases: ["alias-one"], + envVars: ["DEMO_API_KEY"], + auth: [{ id: "primary", label: "Primary" }], + wizard: { + onboarding: { + choiceId: "demo-choice", + }, + modelPicker: { + label: "Demo models", + }, + }, + }); + expect(diagnostics.map((diag) => ({ level: diag.level, message: diag.message }))).toEqual([ + { + level: "error", + message: 'provider "demo" auth method duplicated id "primary"', + }, + { + level: "error", + message: 'provider "demo" auth method missing id', + }, + { + level: "warn", + message: + 'provider "demo" onboarding method "missing" not found; falling back to available methods', + }, + { + level: "warn", + message: + 'provider "demo" model-picker method "missing" not found; falling back to available methods', + }, + ]); + }); + + it("drops wizard metadata when a provider has no auth methods", () => { + const { diagnostics, pushDiagnostic } = collectDiagnostics(); + + const provider = normalizeRegisteredProvider({ + pluginId: "demo-plugin", + source: "/tmp/demo/index.ts", + provider: makeProvider({ + wizard: { + onboarding: { + choiceId: "demo", + }, + modelPicker: { + label: "Demo", + }, + }, + }), + pushDiagnostic, + }); + + expect(provider?.wizard).toBeUndefined(); + expect(diagnostics.map((diag) => diag.message)).toEqual([ + 'provider "demo" onboarding metadata ignored because it has no auth methods', + 'provider "demo" model-picker metadata ignored because it has no auth methods', + ]); + }); +}); diff --git a/src/plugins/provider-validation.ts b/src/plugins/provider-validation.ts new file mode 100644 index 00000000000..ae7c807ed99 --- /dev/null +++ b/src/plugins/provider-validation.ts @@ -0,0 +1,232 @@ +import type { PluginDiagnostic, ProviderAuthMethod, ProviderPlugin } from "./types.js"; + +function pushProviderDiagnostic(params: { + level: PluginDiagnostic["level"]; + pluginId: string; + source: string; + message: string; + pushDiagnostic: (diag: PluginDiagnostic) => void; +}) { + params.pushDiagnostic({ + level: params.level, + pluginId: params.pluginId, + source: params.source, + message: params.message, + }); +} + +function normalizeText(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function normalizeTextList(values: string[] | undefined): string[] | undefined { + const normalized = Array.from( + new Set((values ?? []).map((value) => value.trim()).filter(Boolean)), + ); + return normalized.length > 0 ? normalized : undefined; +} + +function normalizeProviderAuthMethods(params: { + providerId: string; + pluginId: string; + source: string; + auth: ProviderAuthMethod[]; + pushDiagnostic: (diag: PluginDiagnostic) => void; +}): ProviderAuthMethod[] { + const seenMethodIds = new Set(); + const normalized: ProviderAuthMethod[] = []; + + for (const method of params.auth) { + const methodId = normalizeText(method.id); + if (!methodId) { + pushProviderDiagnostic({ + level: "error", + pluginId: params.pluginId, + source: params.source, + message: `provider "${params.providerId}" auth method missing id`, + pushDiagnostic: params.pushDiagnostic, + }); + continue; + } + if (seenMethodIds.has(methodId)) { + pushProviderDiagnostic({ + level: "error", + pluginId: params.pluginId, + source: params.source, + message: `provider "${params.providerId}" auth method duplicated id "${methodId}"`, + pushDiagnostic: params.pushDiagnostic, + }); + continue; + } + seenMethodIds.add(methodId); + normalized.push({ + ...method, + id: methodId, + label: normalizeText(method.label) ?? methodId, + ...(normalizeText(method.hint) ? { hint: normalizeText(method.hint) } : {}), + }); + } + + return normalized; +} + +function normalizeProviderWizard(params: { + providerId: string; + pluginId: string; + source: string; + auth: ProviderAuthMethod[]; + wizard: ProviderPlugin["wizard"]; + pushDiagnostic: (diag: PluginDiagnostic) => void; +}): ProviderPlugin["wizard"] { + if (!params.wizard) { + return undefined; + } + + const hasAuthMethods = params.auth.length > 0; + const hasMethod = (methodId: string | undefined) => + Boolean(methodId && params.auth.some((method) => method.id === methodId)); + + const normalizeOnboarding = () => { + const onboarding = params.wizard?.onboarding; + if (!onboarding) { + return undefined; + } + if (!hasAuthMethods) { + pushProviderDiagnostic({ + level: "warn", + pluginId: params.pluginId, + source: params.source, + message: `provider "${params.providerId}" onboarding metadata ignored because it has no auth methods`, + pushDiagnostic: params.pushDiagnostic, + }); + return undefined; + } + const methodId = normalizeText(onboarding.methodId); + if (methodId && !hasMethod(methodId)) { + pushProviderDiagnostic({ + level: "warn", + pluginId: params.pluginId, + source: params.source, + message: `provider "${params.providerId}" onboarding method "${methodId}" not found; falling back to available methods`, + pushDiagnostic: params.pushDiagnostic, + }); + } + return { + ...(normalizeText(onboarding.choiceId) + ? { choiceId: normalizeText(onboarding.choiceId) } + : {}), + ...(normalizeText(onboarding.choiceLabel) + ? { choiceLabel: normalizeText(onboarding.choiceLabel) } + : {}), + ...(normalizeText(onboarding.choiceHint) + ? { choiceHint: normalizeText(onboarding.choiceHint) } + : {}), + ...(normalizeText(onboarding.groupId) ? { groupId: normalizeText(onboarding.groupId) } : {}), + ...(normalizeText(onboarding.groupLabel) + ? { groupLabel: normalizeText(onboarding.groupLabel) } + : {}), + ...(normalizeText(onboarding.groupHint) + ? { groupHint: normalizeText(onboarding.groupHint) } + : {}), + ...(methodId && hasMethod(methodId) ? { methodId } : {}), + }; + }; + + const normalizeModelPicker = () => { + const modelPicker = params.wizard?.modelPicker; + if (!modelPicker) { + return undefined; + } + if (!hasAuthMethods) { + pushProviderDiagnostic({ + level: "warn", + pluginId: params.pluginId, + source: params.source, + message: `provider "${params.providerId}" model-picker metadata ignored because it has no auth methods`, + pushDiagnostic: params.pushDiagnostic, + }); + return undefined; + } + const methodId = normalizeText(modelPicker.methodId); + if (methodId && !hasMethod(methodId)) { + pushProviderDiagnostic({ + level: "warn", + pluginId: params.pluginId, + source: params.source, + message: `provider "${params.providerId}" model-picker method "${methodId}" not found; falling back to available methods`, + pushDiagnostic: params.pushDiagnostic, + }); + } + return { + ...(normalizeText(modelPicker.label) ? { label: normalizeText(modelPicker.label) } : {}), + ...(normalizeText(modelPicker.hint) ? { hint: normalizeText(modelPicker.hint) } : {}), + ...(methodId && hasMethod(methodId) ? { methodId } : {}), + }; + }; + + const onboarding = normalizeOnboarding(); + const modelPicker = normalizeModelPicker(); + if (!onboarding && !modelPicker) { + return undefined; + } + return { + ...(onboarding ? { onboarding } : {}), + ...(modelPicker ? { modelPicker } : {}), + }; +} + +export function normalizeRegisteredProvider(params: { + pluginId: string; + source: string; + provider: ProviderPlugin; + pushDiagnostic: (diag: PluginDiagnostic) => void; +}): ProviderPlugin | null { + const id = normalizeText(params.provider.id); + if (!id) { + pushProviderDiagnostic({ + level: "error", + pluginId: params.pluginId, + source: params.source, + message: "provider registration missing id", + pushDiagnostic: params.pushDiagnostic, + }); + return null; + } + + const auth = normalizeProviderAuthMethods({ + providerId: id, + pluginId: params.pluginId, + source: params.source, + auth: params.provider.auth ?? [], + pushDiagnostic: params.pushDiagnostic, + }); + const docsPath = normalizeText(params.provider.docsPath); + const aliases = normalizeTextList(params.provider.aliases); + const envVars = normalizeTextList(params.provider.envVars); + const wizard = normalizeProviderWizard({ + providerId: id, + pluginId: params.pluginId, + source: params.source, + auth, + wizard: params.provider.wizard, + pushDiagnostic: params.pushDiagnostic, + }); + const { + wizard: _ignoredWizard, + docsPath: _ignoredDocsPath, + aliases: _ignoredAliases, + envVars: _ignoredEnvVars, + ...restProvider + } = params.provider; + return { + ...restProvider, + id, + label: normalizeText(params.provider.label) ?? id, + ...(docsPath ? { docsPath } : {}), + ...(aliases ? { aliases } : {}), + ...(envVars ? { envVars } : {}), + auth, + ...(wizard ? { wizard } : {}), + }; +} diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 37947fce707..d45ff136a14 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -13,6 +13,7 @@ import { resolveUserPath } from "../utils.js"; import { registerPluginCommand } from "./commands.js"; import { normalizePluginHttpPath } from "./http-path.js"; import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js"; +import { normalizeRegisteredProvider } from "./provider-validation.js"; import type { PluginRuntime } from "./runtime/types.js"; import { isPluginHookName, @@ -428,16 +429,16 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }; const registerProvider = (record: PluginRecord, provider: ProviderPlugin) => { - const id = typeof provider?.id === "string" ? provider.id.trim() : ""; - if (!id) { - pushDiagnostic({ - level: "error", - pluginId: record.id, - source: record.source, - message: "provider registration missing id", - }); + const normalizedProvider = normalizeRegisteredProvider({ + pluginId: record.id, + source: record.source, + provider, + pushDiagnostic, + }); + if (!normalizedProvider) { return; } + const id = normalizedProvider.id; const existing = registry.providers.find((entry) => entry.provider.id === id); if (existing) { pushDiagnostic({ @@ -451,7 +452,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { record.providerIds.push(id); registry.providers.push({ pluginId: record.id, - provider, + provider: normalizedProvider, source: record.source, }); };