feat(plugins): surface manifest provider setup choices (#71240)

This commit is contained in:
Vincent Koc
2026-04-24 13:14:49 -07:00
committed by GitHub
parent 8154337cb6
commit cf858258c7
5 changed files with 189 additions and 7 deletions

View File

@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
- Plugins/setup: report descriptor/runtime drift when setup-api registrations disagree with `setup.providers` or `setup.cliBackends`, without rejecting legacy setup plugins. Thanks @vincentkoc.
- Plugin hooks: expose first-class run, message, sender, session, and trace correlation fields on message hook contexts and run lifecycle events. Thanks @vincentkoc.
- Plugins/setup: include `setup.providers[].envVars` in generic provider auth/env lookups and warn non-bundled plugins that still rely on deprecated `providerAuthEnvVars` compatibility metadata. Thanks @vincentkoc.
- Plugins/setup: surface manifest provider auth choices directly in provider setup flow before falling back to setup runtime or install-catalog choices. Thanks @vincentkoc.
- TUI/dependencies: remove direct `cli-highlight` usage from the OpenClaw TUI code-block renderer, keeping themed code coloring without the extra root dependency. Thanks @vincentkoc.
- Diagnostics/OTEL: export run, model-call, and tool-execution diagnostic lifecycle events as OTEL spans without retaining live span state. Thanks @vincentkoc.
- Providers/Anthropic Vertex: move the Vertex SDK runtime behind the bundled provider plugin so core no longer owns that provider-specific dependency. Thanks @vincentkoc.

View File

@@ -71,7 +71,9 @@ or fallback behavior without changing runtime loading semantics.
Setup discovery now prefers descriptor-owned ids such as `setup.providers` and
`setup.cliBackends` to narrow candidate plugins before it falls back to
`setup-api` for plugins that still need setup-time runtime hooks. Explicit
`setup-api` for plugins that still need setup-time runtime hooks. Provider
setup flow uses manifest `providerAuthChoices` first, then falls back to
runtime wizard choices and install-catalog choices for compatibility. Explicit
`setup.requiresRuntime: false` is a descriptor-only cutoff; omitted
`requiresRuntime` keeps the legacy setup-api fallback for compatibility. If more
than one discovered plugin claims the same normalized setup provider or CLI

View File

@@ -168,6 +168,8 @@ or npm install metadata. Those belong in your plugin code and `package.json`.
Each `providerAuthChoices` entry describes one onboarding or auth choice.
OpenClaw reads this before provider runtime loads.
Provider setup flow prefers these manifest choices, then falls back to runtime
wizard metadata and install-catalog choices for compatibility.
| Field | Required | Type | What it means |
| --------------------- | -------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------- |

View File

@@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
type ResolveProviderInstallCatalogEntries =
typeof import("../plugins/provider-install-catalog.js").resolveProviderInstallCatalogEntries;
type ResolveManifestProviderAuthChoices =
typeof import("../plugins/provider-auth-choices.js").resolveManifestProviderAuthChoices;
type ResolveProviderWizardOptions =
typeof import("../plugins/provider-wizard.js").resolveProviderWizardOptions;
type ResolveProviderModelPickerEntries =
@@ -16,6 +18,13 @@ vi.mock("../plugins/provider-install-catalog.js", () => ({
resolveProviderInstallCatalogEntries,
}));
const resolveManifestProviderAuthChoices = vi.hoisted(() =>
vi.fn<ResolveManifestProviderAuthChoices>(() => []),
);
vi.mock("../plugins/provider-auth-choices.js", () => ({
resolveManifestProviderAuthChoices,
}));
const resolveProviderWizardOptions = vi.hoisted(() =>
vi.fn<ResolveProviderWizardOptions>(() => []),
);
@@ -39,7 +48,117 @@ import {
describe("provider flow install catalog contributions", () => {
beforeEach(() => {
vi.clearAllMocks();
resolveManifestProviderAuthChoices.mockReset();
resolveManifestProviderAuthChoices.mockReturnValue([]);
resolveProviderInstallCatalogEntries.mockReset();
resolveProviderInstallCatalogEntries.mockReturnValue([]);
resolveProviderWizardOptions.mockReset();
resolveProviderWizardOptions.mockReturnValue([]);
resolveProviderModelPickerEntries.mockReset();
resolveProviderModelPickerEntries.mockReturnValue([]);
resolvePluginProviders.mockReset();
resolvePluginProviders.mockReturnValue([]);
});
it("surfaces manifest provider auth choices before setup runtime loads", () => {
resolveManifestProviderAuthChoices.mockReturnValue([
{
pluginId: "openai-compatible",
providerId: "openai-compatible",
methodId: "api-key",
choiceId: "openai-compatible-api-key",
choiceLabel: "OpenAI-compatible API key",
choiceHint: "Use a compatible endpoint",
assistantPriority: -5,
assistantVisibility: "visible",
groupId: "openai-compatible",
groupLabel: "OpenAI-compatible",
groupHint: "Self-hosted and compatible providers",
onboardingScopes: ["text-inference"],
},
]);
expect(resolveProviderSetupFlowContributions()).toEqual([
{
id: "provider:setup:openai-compatible-api-key",
kind: "provider",
surface: "setup",
providerId: "openai-compatible",
pluginId: "openai-compatible",
option: {
value: "openai-compatible-api-key",
label: "OpenAI-compatible API key",
hint: "Use a compatible endpoint",
assistantPriority: -5,
assistantVisibility: "visible",
group: {
id: "openai-compatible",
label: "OpenAI-compatible",
hint: "Self-hosted and compatible providers",
},
},
onboardingScopes: ["text-inference"],
source: "manifest",
},
]);
expect(resolveManifestProviderAuthChoices).toHaveBeenCalledWith(
expect.objectContaining({
includeUntrustedWorkspacePlugins: false,
}),
);
});
it("prefers manifest setup contributions over duplicate runtime and install-catalog entries", () => {
resolveManifestProviderAuthChoices.mockReturnValue([
{
pluginId: "openai",
providerId: "openai",
methodId: "api-key",
choiceId: "openai-api-key",
choiceLabel: "OpenAI API key",
},
]);
resolveProviderWizardOptions.mockReturnValue([
{
value: "openai-api-key",
label: "Runtime OpenAI API key",
groupId: "openai",
groupLabel: "OpenAI",
},
]);
resolveProviderInstallCatalogEntries.mockReturnValue([
{
pluginId: "openai",
providerId: "openai",
methodId: "api-key",
choiceId: "openai-api-key",
choiceLabel: "Catalog OpenAI API key",
label: "OpenAI",
origin: "bundled",
install: {
npmSpec: "@openclaw/openai",
},
},
]);
expect(resolveProviderSetupFlowContributions()).toEqual([
{
id: "provider:setup:openai-api-key",
kind: "provider",
surface: "setup",
providerId: "openai",
pluginId: "openai",
option: {
value: "openai-api-key",
label: "OpenAI API key",
group: {
id: "openai",
label: "OpenAI API key",
},
},
source: "manifest",
},
]);
});
it("surfaces install-catalog provider choices when runtime setup options are absent", () => {

View File

@@ -1,5 +1,6 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizePluginsConfig, resolveEffectiveEnableState } from "../plugins/config-state.js";
import { resolveManifestProviderAuthChoices } from "../plugins/provider-auth-choices.js";
import { resolveProviderInstallCatalogEntries } from "../plugins/provider-install-catalog.js";
import {
resolveProviderModelPickerEntries,
@@ -28,7 +29,7 @@ export type ProviderSetupFlowContribution = FlowContribution & {
pluginId?: string;
option: ProviderSetupFlowOption;
onboardingScopes?: ProviderFlowScope[];
source: "runtime" | "install-catalog";
source: "manifest" | "runtime" | "install-catalog";
};
export type ProviderModelPickerFlowContribution = FlowContribution & {
@@ -121,6 +122,51 @@ function resolveInstallCatalogProviderSetupFlowContributions(params?: {
});
}
function resolveManifestProviderSetupFlowContributions(params?: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
scope?: ProviderFlowScope;
}): ProviderSetupFlowContribution[] {
const scope = params?.scope ?? DEFAULT_PROVIDER_FLOW_SCOPE;
return resolveManifestProviderAuthChoices({
...params,
includeUntrustedWorkspacePlugins: false,
})
.filter((choice) => includesProviderFlowScope(choice.onboardingScopes, scope))
.map((choice) => {
const groupId = choice.groupId ?? choice.providerId;
const groupLabel = choice.groupLabel ?? choice.choiceLabel;
return Object.assign(
{
id: `provider:setup:${choice.choiceId}`,
kind: `provider` as const,
surface: `setup` as const,
providerId: choice.providerId,
pluginId: choice.pluginId,
option: {
value: choice.choiceId,
label: choice.choiceLabel,
...(choice.choiceHint ? { hint: choice.choiceHint } : {}),
...(choice.assistantPriority !== undefined
? { assistantPriority: choice.assistantPriority }
: {}),
...(choice.assistantVisibility
? { assistantVisibility: choice.assistantVisibility }
: {}),
group: {
id: groupId,
label: groupLabel,
...(choice.groupHint ? { hint: choice.groupHint } : {}),
},
},
},
choice.onboardingScopes ? { onboardingScopes: [...choice.onboardingScopes] } : {},
{ source: `manifest` as const },
);
});
}
export function resolveProviderSetupFlowContributions(params?: {
config?: OpenClawConfig;
workspaceDir?: string;
@@ -129,8 +175,16 @@ export function resolveProviderSetupFlowContributions(params?: {
}): ProviderSetupFlowContribution[] {
const scope = params?.scope ?? DEFAULT_PROVIDER_FLOW_SCOPE;
const docsByProvider = resolveProviderDocsById(params ?? {});
const manifestContributions = resolveManifestProviderSetupFlowContributions({
...params,
scope,
});
const seenOptionValues = new Set(
manifestContributions.map((contribution) => contribution.option.value),
);
const runtimeContributions = resolveProviderWizardOptions(params ?? {})
.filter((option) => includesProviderFlowScope(option.onboardingScopes, scope))
.filter((option) => !seenOptionValues.has(option.value))
.map((option) =>
Object.assign(
{
@@ -162,14 +216,18 @@ export function resolveProviderSetupFlowContributions(params?: {
{ source: `runtime` as const },
),
);
const seenOptionValues = new Set(
runtimeContributions.map((contribution) => contribution.option.value),
);
for (const contribution of runtimeContributions) {
seenOptionValues.add(contribution.option.value);
}
const installCatalogContributions = resolveInstallCatalogProviderSetupFlowContributions({
...params,
scope,
}).filter((contribution) => !seenOptionValues.has(contribution.option.value));
return sortFlowContributionsByLabel([...runtimeContributions, ...installCatalogContributions]);
return sortFlowContributionsByLabel([
...manifestContributions,
...runtimeContributions,
...installCatalogContributions,
]);
}
export function resolveProviderModelPickerFlowEntries(params?: {