diff --git a/extensions/fal/onboard.test.ts b/extensions/fal/onboard.test.ts index 1006ab3d8ba1..db61a61f9281 100644 --- a/extensions/fal/onboard.test.ts +++ b/extensions/fal/onboard.test.ts @@ -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"); diff --git a/extensions/fal/onboard.ts b/extensions/fal/onboard.ts index 5e3cbdd4dc01..869dea31bdf0 100644 --- a/extensions/fal/onboard.ts +++ b/extensions/fal/onboard.ts @@ -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) { diff --git a/extensions/fal/provider-contract-api.ts b/extensions/fal/provider-contract-api.ts index 660ed9bdcc51..96c2df268821 100644 --- a/extensions/fal/provider-contract-api.ts +++ b/extensions/fal/provider-contract-api.ts @@ -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", diff --git a/extensions/fal/provider-registration.ts b/extensions/fal/provider-registration.ts index f45368e92f3c..a8d8c567dcca 100644 --- a/extensions/fal/provider-registration.ts +++ b/extensions/fal/provider-registration.ts @@ -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: { diff --git a/extensions/vydra/index.ts b/extensions/vydra/index.ts index b93c9c7e60c3..77ab1861dcec 100644 --- a/extensions/vydra/index.ts +++ b/extensions/vydra/index.ts @@ -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: { diff --git a/extensions/vydra/onboard.ts b/extensions/vydra/onboard.ts index b9b910871173..3466664de88b 100644 --- a/extensions/vydra/onboard.ts +++ b/extensions/vydra/onboard.ts @@ -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) { diff --git a/src/commands/auth-choice-options.test.ts b/src/commands/auth-choice-options.test.ts index 16c224553683..ccb3c303687b 100644 --- a/src/commands/auth-choice-options.test.ts +++ b/src/commands/auth-choice-options.test.ts @@ -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); }); }); diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 19e8ca24d121..37c236c31b98 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -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("|"); diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts index 97fcc63419cf..8e0b90957d5f 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts @@ -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: { diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts index 2aef6f2cf160..808a6263c9a8 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts @@ -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, }), diff --git a/src/flows/provider-flow.test.ts b/src/flows/provider-flow.test.ts index 816f50e3ed1f..f4534e205232 100644 --- a/src/flows/provider-flow.test.ts +++ b/src/flows/provider-flow.test.ts @@ -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([ { diff --git a/src/flows/provider-flow.ts b/src/flows/provider-flow.ts index 996b2dbed61b..91fa96f01fad 100644 --- a/src/flows/provider-flow.ts +++ b/src/flows/provider-flow.ts @@ -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,