fix(onboard): activate media-only provider authentication (#126711)

* fix(onboard): activate media-only provider authentication

* refactor(onboard): keep media model defaults plugin-private
This commit is contained in:
Peter Steinberger
2026-08-20 08:43:40 -07:00
committed by GitHub
parent 8c6c7a30cf
commit e50f73d331
12 changed files with 149 additions and 38 deletions
+2 -2
View File
@@ -4,7 +4,7 @@ import {
resolveAgentModelPrimaryValue,
} from "openclaw/plugin-sdk/provider-onboard";
import { describe, expect, it } from "vitest";
import { applyFalConfig, FAL_DEFAULT_IMAGE_MODEL_REF } from "./onboard.js";
import { applyFalConfig } from "./onboard.js";
const emptyCfg: OpenClawConfig = {};
@@ -13,7 +13,7 @@ describe("applyFalConfig", () => {
const result = applyFalConfig(emptyCfg);
expect(resolveAgentModelPrimaryValue(result.agents?.defaults?.mediaModels?.image)).toBe(
FAL_DEFAULT_IMAGE_MODEL_REF,
"fal/fal-ai/flux/dev",
);
// The retired key must stay untouched: nothing in the runtime reads it.
expect(result.agents?.defaults).not.toHaveProperty("imageGenerationModel");
+1 -1
View File
@@ -1,7 +1,7 @@
// Fal setup module handles plugin onboarding behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard";
export const FAL_DEFAULT_IMAGE_MODEL_REF = "fal/fal-ai/flux/dev";
const FAL_DEFAULT_IMAGE_MODEL_REF = "fal/fal-ai/flux/dev";
export function applyFalConfig(cfg: OpenClawConfig): OpenClawConfig {
if (cfg.agents?.defaults?.mediaModels?.image) {
+1 -2
View File
@@ -2,7 +2,6 @@
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
const PROVIDER_ID = "fal";
const FAL_DEFAULT_IMAGE_MODEL_REF = "fal/fal-ai/flux/dev";
export function createFalProvider(): ProviderPlugin {
return {
@@ -16,7 +15,7 @@ export function createFalProvider(): ProviderPlugin {
kind: "api_key",
label: "fal API key",
hint: "Image, video, and music generation API key",
run: async () => ({ profiles: [], defaultModel: FAL_DEFAULT_IMAGE_MODEL_REF }),
run: async () => ({ profiles: [] }),
wizard: {
choiceId: "fal-api-key",
choiceLabel: "fal API key",
+1 -2
View File
@@ -1,7 +1,7 @@
// Fal provider module implements model/runtime integration.
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
import { applyFalConfig, FAL_DEFAULT_IMAGE_MODEL_REF } from "./onboard.js";
import { applyFalConfig } from "./onboard.js";
const PROVIDER_ID = "fal";
@@ -21,7 +21,6 @@ export function createFalProvider(): ProviderPlugin {
flagName: "--fal-api-key",
envVar: "FAL_KEY",
promptMessage: "Enter fal API key",
defaultModel: FAL_DEFAULT_IMAGE_MODEL_REF,
expectedProviders: ["fal"],
applyConfig: (cfg) => applyFalConfig(cfg),
wizard: {
+1 -2
View File
@@ -2,7 +2,7 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
import { buildVydraImageGenerationProvider } from "./image-generation-provider.js";
import { applyVydraConfig, VYDRA_DEFAULT_IMAGE_MODEL_REF } from "./onboard.js";
import { applyVydraConfig } from "./onboard.js";
import { buildVydraSpeechProvider } from "./speech-provider.js";
import { buildVydraVideoGenerationProvider } from "./video-generation-provider.js";
@@ -28,7 +28,6 @@ export default definePluginEntry({
flagName: "--vydra-api-key",
envVar: "VYDRA_API_KEY",
promptMessage: "Enter Vydra API key",
defaultModel: VYDRA_DEFAULT_IMAGE_MODEL_REF,
expectedProviders: [PROVIDER_ID],
applyConfig: (cfg) => applyVydraConfig(cfg),
wizard: {
+1 -1
View File
@@ -1,7 +1,7 @@
// Vydra setup module handles plugin onboarding behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard";
export const VYDRA_DEFAULT_IMAGE_MODEL_REF = "vydra/grok-imagine";
const VYDRA_DEFAULT_IMAGE_MODEL_REF = "vydra/grok-imagine";
export function applyVydraConfig(cfg: OpenClawConfig): OpenClawConfig {
if (cfg.agents?.defaults?.mediaModels?.image) {
+28 -4
View File
@@ -27,14 +27,14 @@ vi.mock("./auth-choice-legacy.js", () => ({
function includesOnboardingScope(
scopes: readonly ("text-inference" | "image-generation" | "music-generation")[] | undefined,
scope: "text-inference" | "image-generation" | "music-generation",
scope: "text-inference" | "image-generation" | "music-generation" | "all",
): boolean {
return scopes ? scopes.includes(scope) : scope === "text-inference";
return scope === "all" || (scopes ? scopes.includes(scope) : scope === "text-inference");
}
vi.mock("../flows/provider-flow.js", () => ({
resolveProviderSetupFlowContributions: vi.fn(
(params?: { scope?: "text-inference" | "image-generation" | "music-generation" }) => {
(params?: { scope?: "text-inference" | "image-generation" | "music-generation" | "all" }) => {
const scope = params?.scope ?? "text-inference";
return [
...resolveManifestProviderAuthChoices()
@@ -717,7 +717,7 @@ describe("buildAuthChoiceOptions", () => {
expect(openCodeValues).toContain("opencode-go");
});
it("hides media-generation-only providers from the interactive auth picker", () => {
it("keeps media-generation auth choices available to the CLI but out of the interactive picker", () => {
resolveManifestProviderAuthChoices.mockReturnValue([
{
pluginId: "fal",
@@ -727,6 +727,16 @@ describe("buildAuthChoiceOptions", () => {
choiceLabel: "fal API key",
groupId: "fal",
groupLabel: "fal",
onboardingScopes: ["image-generation", "music-generation"],
},
{
pluginId: "vydra",
providerId: "vydra",
methodId: "api-key",
choiceId: "vydra-api-key",
choiceLabel: "Vydra API key",
groupId: "vydra",
groupLabel: "Vydra",
onboardingScopes: ["image-generation"],
},
{
@@ -774,12 +784,26 @@ describe("buildAuthChoiceOptions", () => {
const options = getOptions();
const optionValues = options.map((option) => option.value);
const cliChoiceValues = formatAuthChoiceChoicesForCli({
includeLegacyAliases: false,
includeSkip: true,
}).split("|");
expect(optionValues).toContain("openai-api-key");
expect(optionValues).toContain("ollama");
expect(optionValues).not.toContain("fal-api-key");
expect(optionValues).not.toContain("vydra-api-key");
expect(optionValues).not.toContain("openrouter-api-key");
expect(optionValues).not.toContain("local-image-runtime");
expect(optionValues).not.toContain("local-music-runtime");
expect(cliChoiceValues).toEqual(
expect.arrayContaining([
"openai-api-key",
"fal-api-key",
"vydra-api-key",
"openrouter-api-key",
]),
);
expect(cliChoiceValues.filter((choice) => choice === "fal-api-key")).toHaveLength(1);
});
});
+3 -4
View File
@@ -82,10 +82,9 @@ export function formatAuthChoiceChoicesForCli(params?: {
}): string {
const values = [
...formatStaticAuthChoiceChoicesForCli(params).split("|"),
...resolveProviderSetupFlowContributions({
...params,
scope: "text-inference",
}).map((contribution) => contribution.option.value),
...resolveProviderSetupFlowContributions({ ...params, scope: "all" }).map(
(contribution) => contribution.option.value,
),
];
return uniqueStrings(values).join("|");
@@ -287,6 +287,52 @@ describe("applyNonInteractivePluginProviderChoice", () => {
expect(result).toEqual({ plugins: { allow: ["vllm"] } });
});
it("loads a media setup provider without treating it as a text model provider", async () => {
const runtime = createRuntime();
const provider = { id: "pixverse", pluginId: "pixverse", label: "PixVerse" };
const initialConfig: OpenClawConfig = {
agents: { defaults: { model: { primary: "openai/gpt-5.6" } } },
};
const runNonInteractive = vi.fn(async ({ config }: { config: OpenClawConfig }) => ({
...config,
agents: {
...config.agents,
defaults: {
...config.agents?.defaults,
mediaModels: { video: { primary: "pixverse/pixverse-v5.6" } },
},
},
}));
resolvePreferredProviderForAuthChoice.mockResolvedValue("pixverse" as never);
resolvePluginProvidersCore.mockImplementation((...args: unknown[]) => {
const input = args[0] as { providerRefs?: string[] } | undefined;
return (input?.providerRefs?.includes("pixverse") ? [provider] : []) as never;
});
resolveProviderPluginChoice.mockImplementation((...args: unknown[]) => {
const input = args[0] as { providers?: unknown[] } | undefined;
return input?.providers?.includes(provider)
? { provider, method: { runNonInteractive } }
: undefined;
});
const result = await applyNonInteractivePluginProviderChoice({
nextConfig: initialConfig,
authChoice: "pixverse-api-key",
opts: { pixverseApiKey: "pixverse-test-key" } as never,
runtime: runtime as never,
baseConfig: initialConfig,
target,
resolveApiKey: vi.fn(),
toApiKeyCredential: vi.fn(),
});
expect(runNonInteractive).toHaveBeenCalledOnce();
expect(result?.agents?.defaults?.model).toEqual({ primary: "openai/gpt-5.6" });
expect(result?.agents?.defaults?.mediaModels?.video).toEqual({
primary: "pixverse/pixverse-v5.6",
});
});
it("installs an official catalog provider before applying a cold auth choice", async () => {
const runtime = createRuntime();
const runNonInteractive = vi.fn(async ({ config }: { config: OpenClawConfig }) => ({
@@ -300,6 +346,7 @@ describe("applyNonInteractivePluginProviderChoice", () => {
const provider = { id: "groq", pluginId: "groq", label: "Groq" };
resolveProviderInstallCatalogEntry.mockReturnValue({
pluginId: "groq",
providerId: "groq",
label: "Groq",
origin: "bundled",
install: {
@@ -358,6 +405,7 @@ describe("applyNonInteractivePluginProviderChoice", () => {
}),
);
expect(resolvePluginProvidersCore).toHaveBeenCalledTimes(2);
expect(mockArg(resolvePluginProvidersCore, 1).providerRefs).toEqual(["groq"]);
expect(runNonInteractive).toHaveBeenCalledOnce();
expect(result).toMatchObject({
agents: {
@@ -91,6 +91,7 @@ export async function applyNonInteractivePluginProviderChoice(params: {
config: nextConfig,
workspaceDir,
onlyPluginIds: owningPluginIds,
...(preferredProviderId ? { providerRefs: [preferredProviderId] } : {}),
mode: "setup",
includeUntrustedWorkspacePlugins: false,
}),
@@ -189,6 +190,7 @@ export async function applyNonInteractivePluginProviderChoice(params: {
config: nextConfig,
workspaceDir,
onlyPluginIds: [installCatalogEntry.pluginId],
providerRefs: [installCatalogEntry.providerId],
mode: "setup",
includeUntrustedWorkspacePlugins: false,
}),
+41
View File
@@ -128,6 +128,47 @@ describe("provider flow install catalog contributions", () => {
expect(resolvePluginProvidersCore).not.toHaveBeenCalled();
});
it("resolves text and media setup choices in one metadata-only pass", () => {
resolveManifestProviderAuthChoices.mockReturnValue([
{
pluginId: "fal",
providerId: "fal",
methodId: "api-key",
choiceId: "fal-api-key",
choiceLabel: "fal API key",
onboardingScopes: ["image-generation", "music-generation"],
},
{
pluginId: "openai",
providerId: "openai",
methodId: "api-key",
choiceId: "openai-api-key",
choiceLabel: "OpenAI API key",
},
]);
resolveProviderInstallCatalogEntries.mockReturnValue([
{
pluginId: "vydra",
providerId: "vydra",
methodId: "api-key",
choiceId: "vydra-api-key",
choiceLabel: "Vydra API key",
onboardingScopes: ["image-generation"],
label: "Vydra",
origin: "bundled",
install: { npmSpec: "@openclaw/vydra-provider" },
},
]);
expect(
resolveProviderSetupFlowContributions({ scope: "all" }).map(({ option }) => option.value),
).toEqual(expect.arrayContaining(["fal-api-key", "openai-api-key", "vydra-api-key"]));
expect(resolveManifestProviderAuthChoices).toHaveBeenCalledOnce();
expect(resolveProviderInstallCatalogEntries).toHaveBeenCalledOnce();
expect(resolveProviderWizardOptions).not.toHaveBeenCalled();
expect(resolvePluginProvidersCore).not.toHaveBeenCalled();
});
it("prefers manifest setup contributions over duplicate install-catalog entries", () => {
resolveManifestProviderAuthChoices.mockReturnValue([
{
+20 -20
View File
@@ -11,6 +11,13 @@ type ProviderFlowScope = "text-inference" | "image-generation" | "music-generati
const DEFAULT_PROVIDER_FLOW_SCOPE: ProviderFlowScope = "text-inference";
type ProviderSetupFlowParams = {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
scope?: ProviderFlowScope | "all";
};
type ProviderSetupFlowOption = FlowOption & {
onboardingScopes?: ProviderFlowScope[];
onboardingFeatured?: boolean;
@@ -28,18 +35,17 @@ type ProviderSetupFlowContribution = FlowContribution & {
function includesProviderFlowScope(
scopes: readonly ProviderFlowScope[] | undefined,
scope: ProviderFlowScope,
scope: ProviderFlowScope | "all",
): boolean {
// Missing scope means the historic text-inference onboarding surface only.
return scopes ? scopes.includes(scope) : scope === DEFAULT_PROVIDER_FLOW_SCOPE;
return (
scope === "all" || (scopes ? scopes.includes(scope) : scope === DEFAULT_PROVIDER_FLOW_SCOPE)
);
}
function resolveInstallCatalogProviderSetupFlowContributions(params?: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
scope?: ProviderFlowScope;
}): ProviderSetupFlowContribution[] {
function resolveInstallCatalogProviderSetupFlowContributions(
params?: ProviderSetupFlowParams,
): ProviderSetupFlowContribution[] {
const scope = params?.scope ?? DEFAULT_PROVIDER_FLOW_SCOPE;
const normalizedPluginsConfig = normalizePluginsConfig(params?.config?.plugins);
return providerInstallCatalog
@@ -91,12 +97,9 @@ function resolveInstallCatalogProviderSetupFlowContributions(params?: {
});
}
function resolveManifestProviderSetupFlowContributions(params?: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
scope?: ProviderFlowScope;
}): ProviderSetupFlowContribution[] {
function resolveManifestProviderSetupFlowContributions(
params?: ProviderSetupFlowParams,
): ProviderSetupFlowContribution[] {
const scope = params?.scope ?? DEFAULT_PROVIDER_FLOW_SCOPE;
return providerAuthChoices
.resolveManifestProviderAuthChoices({
@@ -138,12 +141,9 @@ function resolveManifestProviderSetupFlowContributions(params?: {
});
}
export function resolveProviderSetupFlowContributions(params?: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
scope?: ProviderFlowScope;
}): ProviderSetupFlowContribution[] {
export function resolveProviderSetupFlowContributions(
params?: ProviderSetupFlowParams,
): ProviderSetupFlowContribution[] {
const scope = params?.scope ?? DEFAULT_PROVIDER_FLOW_SCOPE;
const manifestContributions = resolveManifestProviderSetupFlowContributions({
...params,