fix(providers): keep setup flow on cold metadata

This commit is contained in:
Vincent Koc
2026-04-25 22:46:43 -07:00
parent fd35ba2cad
commit 194818960c
5 changed files with 95 additions and 136 deletions

View File

@@ -72,8 +72,8 @@ 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. Provider
setup flow uses manifest `providerAuthChoices` first, then falls back to
runtime wizard choices and install-catalog choices for compatibility. Explicit
setup lists use manifest `providerAuthChoices`, descriptor-derived setup
choices, and install-catalog metadata without loading provider runtime. 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

@@ -1,7 +1,7 @@
import {
resolveProviderModelPickerFlowContributions,
resolveProviderModelPickerFlowEntries,
} from "../flows/provider-flow.js";
} from "../flows/provider-flow.runtime.js";
import { runProviderPluginAuthMethod } from "../plugins/provider-auth-choice.js";
import {
resolveProviderPluginChoice,

View File

@@ -0,0 +1,79 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
resolveProviderModelPickerEntries,
type ProviderModelPickerEntry,
} from "../plugins/provider-wizard.js";
import { resolvePluginProviders } from "../plugins/providers.runtime.js";
import type { ProviderPlugin } from "../plugins/types.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import type { FlowContribution } from "./types.js";
import { sortFlowContributionsByLabel } from "./types.js";
export type ProviderModelPickerFlowEntry = ProviderModelPickerEntry;
export type ProviderModelPickerFlowContribution = FlowContribution & {
kind: "provider";
surface: "model-picker";
providerId: string;
option: ProviderModelPickerFlowEntry;
source: "runtime";
};
function resolveProviderDocsById(params?: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): Map<string, string> {
return new Map(
resolvePluginProviders({
config: params?.config,
workspaceDir: params?.workspaceDir,
env: params?.env,
mode: "setup",
})
.filter((provider): provider is ProviderPlugin & { docsPath: string } =>
Boolean(normalizeOptionalString(provider.docsPath)),
)
.map((provider) => [provider.id, normalizeOptionalString(provider.docsPath)!]),
);
}
export function resolveProviderModelPickerFlowEntries(params?: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): ProviderModelPickerFlowEntry[] {
return resolveProviderModelPickerFlowContributions(params).map(
(contribution) => contribution.option,
);
}
export function resolveProviderModelPickerFlowContributions(params?: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): ProviderModelPickerFlowContribution[] {
const docsByProvider = resolveProviderDocsById(params ?? {});
return sortFlowContributionsByLabel(
resolveProviderModelPickerEntries(params ?? {}).map((entry) => {
const providerId = entry.value.startsWith("provider-plugin:")
? entry.value.slice("provider-plugin:".length).split(":")[0]
: entry.value;
return {
id: `provider:model-picker:${entry.value}`,
kind: "provider" as const,
surface: "model-picker" as const,
providerId,
option: {
value: entry.value,
label: entry.label,
...(entry.hint ? { hint: entry.hint } : {}),
...(docsByProvider.get(providerId)
? { docs: { path: docsByProvider.get(providerId)! } }
: {}),
},
source: "runtime" as const,
};
}),
);
}

View File

@@ -41,10 +41,8 @@ vi.mock("../plugins/providers.runtime.js", () => ({
resolvePluginProviders,
}));
import {
resolveProviderModelPickerFlowContributions,
resolveProviderSetupFlowContributions,
} from "./provider-flow.js";
import { resolveProviderSetupFlowContributions } from "./provider-flow.js";
import { resolveProviderModelPickerFlowContributions } from "./provider-flow.runtime.js";
describe("provider flow install catalog contributions", () => {
beforeEach(() => {
@@ -106,9 +104,11 @@ describe("provider flow install catalog contributions", () => {
includeUntrustedWorkspacePlugins: false,
}),
);
expect(resolveProviderWizardOptions).not.toHaveBeenCalled();
expect(resolvePluginProviders).not.toHaveBeenCalled();
});
it("prefers manifest setup contributions over duplicate runtime and install-catalog entries", () => {
it("prefers manifest setup contributions over duplicate install-catalog entries", () => {
resolveManifestProviderAuthChoices.mockReturnValue([
{
pluginId: "openai",
@@ -118,14 +118,6 @@ describe("provider flow install catalog contributions", () => {
choiceLabel: "OpenAI API key",
},
]);
resolveProviderWizardOptions.mockReturnValue([
{
value: "openai-api-key",
label: "Runtime OpenAI API key",
groupId: "openai",
groupLabel: "OpenAI",
},
]);
resolveProviderInstallCatalogEntries.mockReturnValue([
{
pluginId: "openai",
@@ -159,6 +151,7 @@ describe("provider flow install catalog contributions", () => {
source: "manifest",
},
]);
expect(resolveProviderWizardOptions).not.toHaveBeenCalled();
});
it("surfaces install-catalog provider choices when runtime setup options are absent", () => {
@@ -299,7 +292,7 @@ describe("provider flow install catalog contributions", () => {
).toEqual([]);
});
it("prefers runtime setup contributions over duplicate install-catalog entries", () => {
it("keeps setup contributions on cold metadata instead of runtime wizard options", () => {
resolveProviderWizardOptions.mockReturnValue([
{
value: "openai-api-key",
@@ -331,6 +324,7 @@ describe("provider flow install catalog contributions", () => {
kind: "provider",
surface: "setup",
providerId: "openai",
pluginId: "openai",
option: {
value: "openai-api-key",
label: "OpenAI API key",
@@ -339,9 +333,11 @@ describe("provider flow install catalog contributions", () => {
label: "OpenAI",
},
},
source: "runtime",
source: "install-catalog",
},
]);
expect(resolveProviderWizardOptions).not.toHaveBeenCalled();
expect(resolvePluginProviders).not.toHaveBeenCalled();
});
it("keeps docs attached to runtime model-picker contributions", () => {

View File

@@ -2,13 +2,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,
resolveProviderWizardOptions,
} from "../plugins/provider-wizard.js";
import { resolvePluginProviders } from "../plugins/providers.runtime.js";
import type { ProviderPlugin } from "../plugins/types.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import type { FlowContribution, FlowOption } from "./types.js";
import { sortFlowContributionsByLabel } from "./types.js";
@@ -29,15 +22,7 @@ export type ProviderSetupFlowContribution = FlowContribution & {
pluginId?: string;
option: ProviderSetupFlowOption;
onboardingScopes?: ProviderFlowScope[];
source: "manifest" | "runtime" | "install-catalog";
};
export type ProviderModelPickerFlowContribution = FlowContribution & {
kind: "provider";
surface: "model-picker";
providerId: string;
option: ProviderModelPickerFlowEntry;
source: "runtime";
source: "manifest" | "install-catalog";
};
function includesProviderFlowScope(
@@ -47,25 +32,6 @@ function includesProviderFlowScope(
return scopes ? scopes.includes(scope) : scope === DEFAULT_PROVIDER_FLOW_SCOPE;
}
function resolveProviderDocsById(params?: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): Map<string, string> {
return new Map(
resolvePluginProviders({
config: params?.config,
workspaceDir: params?.workspaceDir,
env: params?.env,
mode: "setup",
})
.filter((provider): provider is ProviderPlugin & { docsPath: string } =>
Boolean(normalizeOptionalString(provider.docsPath)),
)
.map((provider) => [provider.id, normalizeOptionalString(provider.docsPath)!]),
);
}
function resolveInstallCatalogProviderSetupFlowContributions(params?: {
config?: OpenClawConfig;
workspaceDir?: string;
@@ -174,7 +140,6 @@ export function resolveProviderSetupFlowContributions(params?: {
scope?: ProviderFlowScope;
}): ProviderSetupFlowContribution[] {
const scope = params?.scope ?? DEFAULT_PROVIDER_FLOW_SCOPE;
const docsByProvider = resolveProviderDocsById(params ?? {});
const manifestContributions = resolveManifestProviderSetupFlowContributions({
...params,
scope,
@@ -182,92 +147,11 @@ export function resolveProviderSetupFlowContributions(params?: {
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(
{
id: `provider:setup:${option.value}`,
kind: `provider` as const,
surface: `setup` as const,
providerId: option.groupId,
option: {
value: option.value,
label: option.label,
...(option.hint ? { hint: option.hint } : {}),
...(option.assistantPriority !== undefined
? { assistantPriority: option.assistantPriority }
: {}),
...(option.assistantVisibility
? { assistantVisibility: option.assistantVisibility }
: {}),
group: {
id: option.groupId,
label: option.groupLabel,
...(option.groupHint ? { hint: option.groupHint } : {}),
},
...(docsByProvider.get(option.groupId)
? { docs: { path: docsByProvider.get(option.groupId)! } }
: {}),
},
},
option.onboardingScopes ? { onboardingScopes: [...option.onboardingScopes] } : {},
{ source: `runtime` as const },
),
);
for (const contribution of runtimeContributions) {
seenOptionValues.add(contribution.option.value);
}
const installCatalogContributions = resolveInstallCatalogProviderSetupFlowContributions({
...params,
scope,
}).filter((contribution) => !seenOptionValues.has(contribution.option.value));
return sortFlowContributionsByLabel([
...manifestContributions,
...runtimeContributions,
...installCatalogContributions,
]);
}
export function resolveProviderModelPickerFlowEntries(params?: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): ProviderModelPickerFlowEntry[] {
return resolveProviderModelPickerFlowContributions(params).map(
(contribution) => contribution.option,
);
}
export function resolveProviderModelPickerFlowContributions(params?: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): ProviderModelPickerFlowContribution[] {
const docsByProvider = resolveProviderDocsById(params ?? {});
return sortFlowContributionsByLabel(
resolveProviderModelPickerEntries(params ?? {}).map((entry) => {
const providerId = entry.value.startsWith("provider-plugin:")
? entry.value.slice("provider-plugin:".length).split(":")[0]
: entry.value;
return {
id: `provider:model-picker:${entry.value}`,
kind: "provider" as const,
surface: "model-picker" as const,
providerId,
option: {
value: entry.value,
label: entry.label,
...(entry.hint ? { hint: entry.hint } : {}),
...(docsByProvider.get(providerId)
? { docs: { path: docsByProvider.get(providerId)! } }
: {}),
},
source: "runtime" as const,
};
}),
);
return sortFlowContributionsByLabel([...manifestContributions, ...installCatalogContributions]);
}
export { includesProviderFlowScope };