diff --git a/CHANGELOG.md b/CHANGELOG.md index 463dc87b761..3cd9070be3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/plugins/architecture-internals.md b/docs/plugins/architecture-internals.md index 25be338182b..5d2895ba6a6 100644 --- a/docs/plugins/architecture-internals.md +++ b/docs/plugins/architecture-internals.md @@ -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 diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 1dcca6c6bc9..4617b4393c3 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -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 | | --------------------- | -------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------- | diff --git a/src/flows/provider-flow.test.ts b/src/flows/provider-flow.test.ts index eed6bce0176..72957e8615f 100644 --- a/src/flows/provider-flow.test.ts +++ b/src/flows/provider-flow.test.ts @@ -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(() => []), +); +vi.mock("../plugins/provider-auth-choices.js", () => ({ + resolveManifestProviderAuthChoices, +})); + const resolveProviderWizardOptions = vi.hoisted(() => vi.fn(() => []), ); @@ -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", () => { diff --git a/src/flows/provider-flow.ts b/src/flows/provider-flow.ts index 0f16815b1a4..ad38e5fa45f 100644 --- a/src/flows/provider-flow.ts +++ b/src/flows/provider-flow.ts @@ -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?: {