diff --git a/docs/plugins/plugin-inventory.md b/docs/plugins/plugin-inventory.md index 6ceb03c2d1dc..c5138be7e570 100644 --- a/docs/plugins/plugin-inventory.md +++ b/docs/plugins/plugin-inventory.md @@ -163,6 +163,7 @@ commands. | [nextcloud-talk](/plugins/reference/nextcloud-talk) | Adds the Nextcloud Talk channel surface for sending and receiving OpenClaw messages. | `@openclaw/nextcloud-talk`
npm; ClawHub | channels: nextcloud-talk | | [nostr](/plugins/reference/nostr) | Adds the Nostr channel surface for sending and receiving OpenClaw messages. | `@openclaw/nostr`
npm; ClawHub | channels: nostr | | [openshell](/plugins/reference/openshell) | Sandbox backend powered by the NVIDIA OpenShell CLI with mirrored local workspaces and SSH-based command execution. | `@openclaw/openshell-sandbox`
npm; ClawHub | plugin | +| [pixverse](/plugins/reference/pixverse) | Adds PixVerse video generation provider support to OpenClaw. | `@openclaw/pixverse-provider`
npm; ClawHub | contracts: videoGenerationProviders | | [qqbot](/plugins/reference/qqbot) | Adds the QQ Bot channel surface for sending and receiving OpenClaw messages. | `@openclaw/qqbot`
npm; ClawHub | channels: qqbot; contracts: tools; skills | | [slack](/plugins/reference/slack) | Adds the Slack channel surface for sending and receiving OpenClaw messages. | `@openclaw/slack`
npm; ClawHub | channels: slack | | [synology-chat](/plugins/reference/synology-chat) | Adds the Synology Chat channel surface for sending and receiving OpenClaw messages. | `@openclaw/synology-chat`
npm; ClawHub | channels: synology-chat | diff --git a/docs/plugins/reference.md b/docs/plugins/reference.md index 31f8a46d049b..9a141695c981 100644 --- a/docs/plugins/reference.md +++ b/docs/plugins/reference.md @@ -96,6 +96,7 @@ pnpm plugins:inventory:gen | [openrouter](/plugins/reference/openrouter) | Adds OpenRouter model provider support to OpenClaw. | `@openclaw/openrouter-provider`
included in OpenClaw | providers: openrouter; contracts: imageGenerationProviders, mediaUnderstandingProviders, musicGenerationProviders, speechProviders, videoGenerationProviders | | [openshell](/plugins/reference/openshell) | Sandbox backend powered by the NVIDIA OpenShell CLI with mirrored local workspaces and SSH-based command execution. | `@openclaw/openshell-sandbox`
npm; ClawHub | plugin | | [perplexity](/plugins/reference/perplexity) | Adds web search provider support. | `@openclaw/perplexity-plugin`
included in OpenClaw | contracts: webSearchProviders | +| [pixverse](/plugins/reference/pixverse) | Adds PixVerse video generation provider support to OpenClaw. | `@openclaw/pixverse-provider`
npm; ClawHub | contracts: videoGenerationProviders | | [policy](/plugins/reference/policy) | Adds policy-backed doctor checks for workspace conformance. | `@openclaw/policy`
included in OpenClaw | plugin | | [qa-channel](/plugins/reference/qa-channel) | Adds the QA Channel surface for sending and receiving OpenClaw messages. | `@openclaw/qa-channel`
source checkout only | channels: qa-channel | | [qa-lab](/plugins/reference/qa-lab) | OpenClaw QA lab plugin with private debugger UI and scenario runner. | `@openclaw/qa-lab`
source checkout only | plugin | diff --git a/docs/plugins/reference/pixverse.md b/docs/plugins/reference/pixverse.md new file mode 100644 index 000000000000..8c4e1ece0758 --- /dev/null +++ b/docs/plugins/reference/pixverse.md @@ -0,0 +1,23 @@ +--- +summary: "Adds PixVerse video generation provider support to OpenClaw." +read_when: + - You are installing, configuring, or auditing the pixverse plugin +title: "PixVerse plugin" +--- + +# PixVerse plugin + +Adds PixVerse video generation provider support to OpenClaw. + +## Distribution + +- Package: `@openclaw/pixverse-provider` +- Install route: npm; ClawHub + +## Surface + +contracts: videoGenerationProviders + +## Related docs + +- [pixverse](/providers/pixverse) diff --git a/docs/providers/pixverse.md b/docs/providers/pixverse.md index c775272dce12..89918291fe06 100644 --- a/docs/providers/pixverse.md +++ b/docs/providers/pixverse.md @@ -7,12 +7,12 @@ read_when: - You want to make PixVerse the default video provider --- -OpenClaw ships a bundled `pixverse` provider for hosted PixVerse video generation. The plugin is enabled by default and registers the `pixverse` provider against the `videoGenerationProviders` contract. +OpenClaw provides `pixverse` as an official external plugin for hosted PixVerse video generation. The plugin registers the `pixverse` provider against the `videoGenerationProviders` contract. | Property | Value | | ------------------ | -------------------------------------------------------------------- | | Provider id | `pixverse` | -| Plugin | bundled, `enabledByDefault: true` | +| Plugin package | `@openclaw/pixverse-provider` | | Auth env var | `PIXVERSE_API_KEY` | | Onboarding flag | `--auth-choice pixverse-api-key` | | Direct CLI flag | `--pixverse-api-key ` | @@ -23,10 +23,22 @@ OpenClaw ships a bundled `pixverse` provider for hosted PixVerse video generatio ## Getting started + + ```bash + openclaw plugins install @openclaw/pixverse-provider + openclaw gateway restart + ``` + ```bash openclaw onboard --auth-choice pixverse-api-key ``` + + The wizard asks whether to use the International endpoint + (`https://app-api.pixverse.ai/openapi/v2`) or the CN endpoint + (`https://app-api.pixverseai.cn/openapi/v2`) before writing `region` and + `baseUrl` into the provider config. + ```bash @@ -92,7 +104,13 @@ The video provider accepts these optional provider-specific keys: OpenClaw defaults to the international PixVerse API. Set `models.providers.pixverse.region` - when your key belongs to a specific PixVerse platform region: + manually when your key belongs to a specific PixVerse platform region, or use + `openclaw onboard --auth-choice pixverse-api-key` to choose one in the setup wizard: + + | Region value | PixVerse API base URL | + | --------------- | --------------------------------------------- | + | `international` | `https://app-api.pixverse.ai/openapi/v2` | + | `cn` | `https://app-api.pixverseai.cn/openapi/v2` | ```json5 { @@ -100,6 +118,8 @@ The video provider accepts these optional provider-specific keys: providers: { pixverse: { region: "cn", // "international" or "cn" + baseUrl: "https://app-api.pixverseai.cn/openapi/v2", + models: [], }, }, }, diff --git a/extensions/pixverse/constants.ts b/extensions/pixverse/constants.ts new file mode 100644 index 000000000000..02407fda6420 --- /dev/null +++ b/extensions/pixverse/constants.ts @@ -0,0 +1,12 @@ +export const PIXVERSE_PROVIDER_ID = "pixverse"; + +export const PIXVERSE_BASE_URL_BY_REGION = { + international: "https://app-api.pixverse.ai/openapi/v2", + cn: "https://app-api.pixverseai.cn/openapi/v2", +} as const; + +export type PixVerseApiRegion = keyof typeof PIXVERSE_BASE_URL_BY_REGION; + +export const DEFAULT_PIXVERSE_REGION = "international" satisfies PixVerseApiRegion; +export const DEFAULT_PIXVERSE_MODEL_ID = "v6"; +export const PIXVERSE_DEFAULT_VIDEO_MODEL_REF = `${PIXVERSE_PROVIDER_ID}/${DEFAULT_PIXVERSE_MODEL_ID}`; diff --git a/extensions/pixverse/index.test.ts b/extensions/pixverse/index.test.ts new file mode 100644 index 000000000000..21bca753a3e6 --- /dev/null +++ b/extensions/pixverse/index.test.ts @@ -0,0 +1,196 @@ +import { capturePluginRegistration } from "openclaw/plugin-sdk/plugin-test-runtime"; +import { describe, expect, it, vi } from "vitest"; +import { + PIXVERSE_BASE_URL_BY_REGION, + PIXVERSE_DEFAULT_VIDEO_MODEL_REF, + PIXVERSE_PROVIDER_ID, +} from "./constants.js"; +import plugin from "./index.js"; +import { applyPixVerseConfig, applyPixVerseProviderConfig } from "./onboard.js"; + +function registerPixVerseProvider() { + const captured = capturePluginRegistration(plugin); + expect(captured.videoGenerationProviders.map((provider) => provider.id)).toEqual([ + PIXVERSE_PROVIDER_ID, + ]); + const provider = captured.providers[0]; + if (!provider) { + throw new Error("expected PixVerse setup provider"); + } + expect(provider.id).toBe(PIXVERSE_PROVIDER_ID); + return provider; +} + +function createRuntimeContext(region: "international" | "cn") { + const select = vi.fn(async (params: { message: string }) => { + expect(params.message).toBe("Select PixVerse API region"); + return region; + }); + const ctx = { + config: { + models: { + providers: { + pixverse: { + baseUrl: "https://proxy.example/openapi/v2", + models: [], + params: { quality: "720p" }, + }, + }, + }, + }, + env: {}, + prompter: { + intro: vi.fn(), + outro: vi.fn(), + note: vi.fn(), + select, + multiselect: vi.fn(), + text: vi.fn(async () => "pixverse-test-key"), + confirm: vi.fn(), + progress: vi.fn(() => ({ + update: vi.fn(), + stop: vi.fn(), + })), + }, + runtime: { + error: vi.fn(), + exit: vi.fn(), + log: vi.fn(), + }, + secretInputMode: "plaintext", + isRemote: false, + openUrl: vi.fn(), + oauth: { + createVpsAwareHandlers: vi.fn(), + }, + } as never; + return { ctx, select }; +} + +describe("pixverse plugin", () => { + it("registers provider auth for the setup wizard", () => { + const provider = registerPixVerseProvider(); + const auth = provider?.auth?.[0]; + + expect(provider).toMatchObject({ + id: PIXVERSE_PROVIDER_ID, + label: "PixVerse", + docsPath: "/providers/pixverse", + envVars: ["PIXVERSE_API_KEY"], + }); + expect(auth).toMatchObject({ + id: "api-key", + label: "PixVerse API key", + kind: "api_key", + wizard: { + choiceId: "pixverse-api-key", + choiceLabel: "PixVerse API key", + choiceHint: "Prompts for International or CN endpoint", + groupId: "pixverse", + groupLabel: "PixVerse", + groupHint: "Video generation", + onboardingScopes: ["image-generation"], + }, + }); + }); + + it("prompts for the PixVerse region and writes provider config", async () => { + const provider = registerPixVerseProvider(); + const auth = provider?.auth?.[0]; + if (!auth) { + throw new Error("expected PixVerse auth method"); + } + + const { ctx, select } = createRuntimeContext("cn"); + const result = await auth.run(ctx); + const regionSelect = select.mock.calls[0]?.[0]; + + expect(regionSelect).toEqual({ + message: "Select PixVerse API region", + initialValue: "international", + options: [ + { + value: "international", + label: "International", + hint: PIXVERSE_BASE_URL_BY_REGION.international, + }, + { + value: "cn", + label: "CN", + hint: PIXVERSE_BASE_URL_BY_REGION.cn, + }, + ], + }); + expect(result.profiles).toEqual([ + { + profileId: "pixverse:default", + credential: { + type: "api_key", + provider: PIXVERSE_PROVIDER_ID, + key: "pixverse-test-key", + }, + }, + ]); + expect(result.configPatch?.models?.providers?.pixverse).toEqual({ + baseUrl: PIXVERSE_BASE_URL_BY_REGION.cn, + models: [], + params: { quality: "720p" }, + region: "cn", + }); + expect(result.defaultModel).toBeUndefined(); + expect(result.configPatch?.agents?.defaults?.videoGenerationModel).toEqual({ + primary: PIXVERSE_DEFAULT_VIDEO_MODEL_REF, + }); + expect(result.notes).toEqual([`PixVerse endpoint: CN (${PIXVERSE_BASE_URL_BY_REGION.cn})`]); + }); + + it("only resets custom baseUrl when a region is explicitly selected", () => { + const config = { + models: { + providers: { + pixverse: { + baseUrl: "https://proxy.example/openapi/v2", + models: [], + params: { quality: "720p" }, + }, + }, + }, + }; + + expect( + applyPixVerseProviderConfig(config, "international").models?.providers?.pixverse, + ).toEqual({ + baseUrl: "https://proxy.example/openapi/v2", + models: [], + params: { quality: "720p" }, + region: "international", + }); + expect( + applyPixVerseProviderConfig(config, "cn", { resetBaseUrl: true }).models?.providers?.pixverse, + ).toEqual({ + baseUrl: PIXVERSE_BASE_URL_BY_REGION.cn, + models: [], + params: { quality: "720p" }, + region: "cn", + }); + }); + + it("preserves an existing video generation default", () => { + const result = applyPixVerseConfig( + { + agents: { + defaults: { + videoGenerationModel: { + primary: "openai/sora-2", + }, + }, + }, + }, + "international", + ); + + expect(result.agents?.defaults?.videoGenerationModel).toEqual({ + primary: "openai/sora-2", + }); + }); +}); diff --git a/extensions/pixverse/index.ts b/extensions/pixverse/index.ts index 0e5d5c63a6e3..c97240657d09 100644 --- a/extensions/pixverse/index.ts +++ b/extensions/pixverse/index.ts @@ -1,11 +1,20 @@ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import { PIXVERSE_PROVIDER_ID } from "./constants.js"; +import { buildPixVerseApiKeyAuthMethod } from "./onboard.js"; import { buildPixVerseVideoGenerationProvider } from "./video-generation-provider.js"; export default definePluginEntry({ - id: "pixverse", + id: PIXVERSE_PROVIDER_ID, name: "PixVerse Provider", - description: "Bundled PixVerse video provider plugin", + description: "Official external PixVerse video provider plugin", register(api) { + api.registerProvider({ + id: PIXVERSE_PROVIDER_ID, + label: "PixVerse", + docsPath: "/providers/pixverse", + envVars: ["PIXVERSE_API_KEY"], + auth: [buildPixVerseApiKeyAuthMethod()], + }); api.registerVideoGenerationProvider(buildPixVerseVideoGenerationProvider()); }, }); diff --git a/extensions/pixverse/npm-shrinkwrap.json b/extensions/pixverse/npm-shrinkwrap.json new file mode 100644 index 000000000000..a74d1a15b617 --- /dev/null +++ b/extensions/pixverse/npm-shrinkwrap.json @@ -0,0 +1,20 @@ +{ + "name": "@openclaw/pixverse-provider", + "version": "2026.5.26", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@openclaw/pixverse-provider", + "version": "2026.5.26", + "peerDependencies": { + "openclaw": ">=2026.5.26" + }, + "peerDependenciesMeta": { + "openclaw": { + "optional": true + } + } + } + } +} diff --git a/extensions/pixverse/onboard.ts b/extensions/pixverse/onboard.ts new file mode 100644 index 000000000000..f799a1ab564f --- /dev/null +++ b/extensions/pixverse/onboard.ts @@ -0,0 +1,252 @@ +import { + type ProviderAuthContext, + type ProviderAuthMethod, + type ProviderAuthMethodNonInteractiveContext, +} from "openclaw/plugin-sdk/plugin-entry"; +import { + applyAuthProfileConfig, + buildApiKeyCredential, + ensureApiKeyFromOptionEnvOrPrompt, + normalizeApiKeyInput, + normalizeOptionalSecretInput, + type OpenClawConfig, + type SecretInput, + upsertAuthProfileWithLock, + validateApiKeyInput, +} from "openclaw/plugin-sdk/provider-auth-api-key"; +import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-onboard"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + DEFAULT_PIXVERSE_REGION, + PIXVERSE_BASE_URL_BY_REGION, + PIXVERSE_DEFAULT_VIDEO_MODEL_REF, + PIXVERSE_PROVIDER_ID, + type PixVerseApiRegion, +} from "./constants.js"; + +const PROFILE_ID = `${PIXVERSE_PROVIDER_ID}:default`; + +type PixVerseAuthResult = { + profiles: Array<{ profileId: string; credential: ReturnType }>; + configPatch: OpenClawConfig; + notes: string[]; +}; + +type UpsertAuthProfileParams = Parameters[0]; + +async function upsertAuthProfileWithLockOrThrow(params: UpsertAuthProfileParams): Promise { + const updated = await upsertAuthProfileWithLock(params); + if (!updated) { + throw new Error( + "Failed to update auth profile store; the auth store lock may be busy. Wait a moment and retry.", + ); + } +} + +function normalizePixVerseRegion(value: unknown): PixVerseApiRegion | undefined { + const region = normalizeOptionalString(value)?.toLowerCase(); + switch (region) { + case "cn": + case "china": + case "mainland": + case "pai": + return "cn"; + case "global": + case "intl": + case "international": + return "international"; + default: + return undefined; + } +} + +function pixVerseRegionNote(region: PixVerseApiRegion): string { + const label = region === "cn" ? "CN" : "International"; + return `PixVerse endpoint: ${label} (${PIXVERSE_BASE_URL_BY_REGION[region]})`; +} + +export function applyPixVerseProviderConfig( + cfg: OpenClawConfig, + region: PixVerseApiRegion, + options?: { resetBaseUrl?: boolean }, +): OpenClawConfig { + const existingProvider: Partial = + cfg.models?.providers?.[PIXVERSE_PROVIDER_ID] ?? {}; + const selectedBaseUrl = PIXVERSE_BASE_URL_BY_REGION[region]; + const baseUrl = options?.resetBaseUrl + ? selectedBaseUrl + : (normalizeOptionalString(existingProvider.baseUrl) ?? selectedBaseUrl); + return { + ...cfg, + models: { + ...cfg.models, + providers: { + ...cfg.models?.providers, + [PIXVERSE_PROVIDER_ID]: { + ...existingProvider, + baseUrl, + models: existingProvider.models ?? [], + region, + }, + }, + }, + }; +} + +export function applyPixVerseConfig( + cfg: OpenClawConfig, + region: PixVerseApiRegion, + options?: { resetBaseUrl?: boolean }, +): OpenClawConfig { + const next = applyPixVerseProviderConfig(cfg, region, options); + if (next.agents?.defaults?.videoGenerationModel) { + return next; + } + return { + ...next, + agents: { + ...next.agents, + defaults: { + ...next.agents?.defaults, + videoGenerationModel: { + primary: PIXVERSE_DEFAULT_VIDEO_MODEL_REF, + }, + }, + }, + }; +} + +async function promptForPixVerseRegion(ctx: ProviderAuthContext): Promise { + return await ctx.prompter.select({ + message: "Select PixVerse API region", + initialValue: DEFAULT_PIXVERSE_REGION, + options: [ + { + value: "international", + label: "International", + hint: PIXVERSE_BASE_URL_BY_REGION.international, + }, + { + value: "cn", + label: "CN", + hint: PIXVERSE_BASE_URL_BY_REGION.cn, + }, + ], + }); +} + +async function runPixVerseApiKeyAuth(ctx: ProviderAuthContext): Promise { + let capturedSecretInput: SecretInput | undefined; + let capturedCredential = false; + let capturedMode: "plaintext" | "ref" | undefined; + + await ensureApiKeyFromOptionEnvOrPrompt({ + token: + normalizeOptionalSecretInput(ctx.opts?.pixverseApiKey) ?? + normalizeOptionalSecretInput(ctx.opts?.token), + tokenProvider: normalizeOptionalSecretInput(ctx.opts?.pixverseApiKey) + ? PIXVERSE_PROVIDER_ID + : normalizeOptionalSecretInput(ctx.opts?.tokenProvider), + secretInputMode: + ctx.allowSecretRefPrompt === false + ? (ctx.secretInputMode ?? "plaintext") + : ctx.secretInputMode, + config: ctx.config, + env: ctx.env, + expectedProviders: [PIXVERSE_PROVIDER_ID], + provider: PIXVERSE_PROVIDER_ID, + envLabel: "PIXVERSE_API_KEY", + promptMessage: "Enter PixVerse API key", + normalize: normalizeApiKeyInput, + validate: validateApiKeyInput, + prompter: ctx.prompter, + setCredential: async (apiKey, mode) => { + capturedSecretInput = apiKey; + capturedCredential = true; + capturedMode = mode; + }, + }); + + if (!capturedCredential) { + throw new Error("Missing PixVerse API key."); + } + + const region = await promptForPixVerseRegion(ctx); + return { + profiles: [ + { + profileId: PROFILE_ID, + credential: buildApiKeyCredential( + PIXVERSE_PROVIDER_ID, + capturedSecretInput ?? "", + undefined, + capturedMode + ? { + secretInputMode: capturedMode, + config: ctx.config, + } + : undefined, + ), + }, + ], + configPatch: applyPixVerseConfig(ctx.config, region, { resetBaseUrl: true }), + notes: [pixVerseRegionNote(region)], + }; +} + +async function runPixVerseApiKeyAuthNonInteractive(ctx: ProviderAuthMethodNonInteractiveContext) { + const resolved = await ctx.resolveApiKey({ + provider: PIXVERSE_PROVIDER_ID, + flagValue: normalizeOptionalSecretInput(ctx.opts.pixverseApiKey), + flagName: "--pixverse-api-key", + envVar: "PIXVERSE_API_KEY", + }); + if (!resolved) { + return null; + } + + if (resolved.source !== "profile") { + const credential = ctx.toApiKeyCredential({ + provider: PIXVERSE_PROVIDER_ID, + resolved, + }); + if (!credential) { + return null; + } + await upsertAuthProfileWithLockOrThrow({ + profileId: PROFILE_ID, + credential, + agentDir: ctx.agentDir, + }); + } + + const next = applyAuthProfileConfig(ctx.config, { + profileId: PROFILE_ID, + provider: PIXVERSE_PROVIDER_ID, + mode: "api_key", + }); + const explicitRegion = normalizePixVerseRegion(ctx.opts.pixverseRegion); + return applyPixVerseConfig(next, explicitRegion ?? DEFAULT_PIXVERSE_REGION, { + resetBaseUrl: explicitRegion !== undefined, + }); +} + +export function buildPixVerseApiKeyAuthMethod(): ProviderAuthMethod { + return { + id: "api-key", + label: "PixVerse API key", + hint: "Video generation API key", + kind: "api_key", + wizard: { + choiceId: "pixverse-api-key", + choiceLabel: "PixVerse API key", + choiceHint: "Prompts for International or CN endpoint", + groupId: "pixverse", + groupLabel: "PixVerse", + groupHint: "Video generation", + onboardingScopes: ["image-generation"], + }, + run: runPixVerseApiKeyAuth, + runNonInteractive: runPixVerseApiKeyAuthNonInteractive, + }; +} diff --git a/extensions/pixverse/openclaw.plugin.json b/extensions/pixverse/openclaw.plugin.json index 750f25d368c2..09aeaa843b49 100644 --- a/extensions/pixverse/openclaw.plugin.json +++ b/extensions/pixverse/openclaw.plugin.json @@ -1,5 +1,6 @@ { "id": "pixverse", + "description": "Adds PixVerse video generation provider support to OpenClaw.", "activation": { "onStartup": false }, @@ -13,9 +14,10 @@ "method": "api-key", "choiceId": "pixverse-api-key", "choiceLabel": "PixVerse API key", + "choiceHint": "Wizard prompts for International or CN endpoint.", "groupId": "pixverse", "groupLabel": "PixVerse", - "groupHint": "API key", + "groupHint": "Video generation", "onboardingScopes": ["image-generation"], "optionKey": "pixverseApiKey", "cliFlag": "--pixverse-api-key", diff --git a/extensions/pixverse/package.json b/extensions/pixverse/package.json index 62dee6e961ed..f2fd3d1f43dc 100644 --- a/extensions/pixverse/package.json +++ b/extensions/pixverse/package.json @@ -1,15 +1,42 @@ { "name": "@openclaw/pixverse-provider", "version": "2026.5.26", - "private": true, "description": "OpenClaw PixVerse video provider plugin", + "repository": { + "type": "git", + "url": "https://github.com/openclaw/openclaw" + }, "type": "module", "devDependencies": { - "@openclaw/plugin-sdk": "workspace:*" + "@openclaw/plugin-sdk": "workspace:*", + "openclaw": "workspace:*" + }, + "peerDependencies": { + "openclaw": ">=2026.5.26" + }, + "peerDependenciesMeta": { + "openclaw": { + "optional": true + } }, "openclaw": { "extensions": [ "./index.ts" - ] + ], + "install": { + "npmSpec": "@openclaw/pixverse-provider", + "defaultChoice": "npm", + "minHostVersion": ">=2026.5.26" + }, + "compat": { + "pluginApi": ">=2026.5.26" + }, + "build": { + "openclawVersion": "2026.5.26" + }, + "release": { + "publishToClawHub": true, + "publishToNpm": true + } } } diff --git a/extensions/pixverse/video-generation-provider.test.ts b/extensions/pixverse/video-generation-provider.test.ts index 056444f923c0..ad53fd0ffdc3 100644 --- a/extensions/pixverse/video-generation-provider.test.ts +++ b/extensions/pixverse/video-generation-provider.test.ts @@ -5,8 +5,14 @@ import { import { expectExplicitVideoGenerationCapabilities } from "openclaw/plugin-sdk/provider-test-contracts"; import { beforeAll, describe, expect, it, vi } from "vitest"; -const { postJsonRequestMock, postMultipartRequestMock, fetchWithTimeoutMock } = - getProviderHttpMocks(); +const { + postJsonRequestMock, + postMultipartRequestMock, + fetchWithTimeoutMock, + pollProviderOperationJsonMock, + resolveProviderHttpRequestConfigMock, + sanitizeConfiguredModelProviderRequestMock, +} = getProviderHttpMocks(); let buildPixVerseVideoGenerationProvider: typeof import("./video-generation-provider.js").buildPixVerseVideoGenerationProvider; @@ -32,6 +38,23 @@ function firstMultipartRequest() { return call[0] as { url?: string; body?: FormData; headers?: Headers }; } +function firstPollRequest() { + const [call] = pollProviderOperationJsonMock.mock.calls; + if (!call) { + throw new Error("expected PixVerse status poll request"); + } + return call[0] as { + url?: string; + allowPrivateNetwork?: boolean; + dispatcherPolicy?: unknown; + }; +} + +function pollFetchHeaders(callIndex: number): Headers | undefined { + const [, init] = fetchWithTimeoutMock.mock.calls[callIndex] ?? []; + return (init as { headers?: Headers } | undefined)?.headers; +} + describe("pixverse video generation provider", () => { it("declares explicit mode capabilities", () => { expectExplicitVideoGenerationCapabilities(buildPixVerseVideoGenerationProvider()); @@ -330,6 +353,142 @@ describe("pixverse video generation provider", () => { ); }); + it("uses the guarded provider transport for status polling", async () => { + const dispatcherPolicy = { mode: "direct" }; + resolveProviderHttpRequestConfigMock.mockReturnValueOnce({ + baseUrl: "https://proxy.example/openapi/v2", + allowPrivateNetwork: true, + headers: new Headers({ "API-KEY": "provider-key", "X-Proxy": "enabled" }), + dispatcherPolicy, + } as never); + postJsonRequestMock.mockResolvedValue({ + response: { + json: async () => ({ + ErrCode: 0, + ErrMsg: "success", + Resp: { video_id: 123 }, + }), + }, + release: vi.fn(async () => {}), + }); + fetchWithTimeoutMock.mockResolvedValueOnce({ + json: async () => ({ + ErrCode: 0, + ErrMsg: "success", + Resp: { id: 123, status: 1, url: "https://media.pixverse.ai/out.mp4" }, + }), + headers: new Headers(), + }); + + const provider = buildPixVerseVideoGenerationProvider(); + await provider.generateVideo({ + provider: "pixverse", + model: "v6", + prompt: "custom base", + cfg: {}, + }); + + expect(firstPostJsonRequest().url).toBe("https://proxy.example/openapi/v2/video/text/generate"); + expect(firstPostJsonRequest().headers?.get("X-Proxy")).toBe("enabled"); + expect(firstPollRequest()).toMatchObject({ + url: "https://proxy.example/openapi/v2/video/result/123", + allowPrivateNetwork: true, + dispatcherPolicy, + }); + const pollHeaders = pollFetchHeaders(0); + expect(pollHeaders?.get("X-Proxy")).toBe("enabled"); + }); + + it("passes configured provider request overrides into the HTTP resolver", async () => { + const request = { + allowPrivateNetwork: true, + headers: { "X-Proxy": "enabled" }, + }; + postJsonRequestMock.mockResolvedValue({ + response: { + json: async () => ({ + ErrCode: 0, + ErrMsg: "success", + Resp: { video_id: 123 }, + }), + }, + release: vi.fn(async () => {}), + }); + fetchWithTimeoutMock.mockResolvedValueOnce({ + json: async () => ({ + ErrCode: 0, + ErrMsg: "success", + Resp: { id: 123, status: 1, url: "https://media.pixverse.ai/out.mp4" }, + }), + headers: new Headers(), + }); + + const provider = buildPixVerseVideoGenerationProvider(); + await provider.generateVideo({ + provider: "pixverse", + model: "v6", + prompt: "custom request config", + cfg: { + models: { + providers: { + pixverse: { + request, + }, + }, + }, + } as never, + }); + + expect(sanitizeConfiguredModelProviderRequestMock).toHaveBeenCalledWith(request); + expect(resolveProviderHttpRequestConfigMock).toHaveBeenCalledWith( + expect.objectContaining({ request }), + ); + }); + + it("uses a fresh trace id for each status poll", async () => { + postJsonRequestMock.mockResolvedValue({ + response: { + json: async () => ({ + ErrCode: 0, + ErrMsg: "success", + Resp: { video_id: 123 }, + }), + }, + release: vi.fn(async () => {}), + }); + fetchWithTimeoutMock + .mockResolvedValueOnce({ + json: async () => ({ + ErrCode: 0, + ErrMsg: "success", + Resp: { id: 123, status: 5 }, + }), + headers: new Headers(), + }) + .mockResolvedValueOnce({ + json: async () => ({ + ErrCode: 0, + ErrMsg: "success", + Resp: { id: 123, status: 1, url: "https://media.pixverse.ai/out.mp4" }, + }), + headers: new Headers(), + }); + + const provider = buildPixVerseVideoGenerationProvider(); + await provider.generateVideo({ + provider: "pixverse", + model: "v6", + prompt: "fresh trace ids", + cfg: {}, + }); + + const firstHeaders = pollFetchHeaders(0); + const secondHeaders = pollFetchHeaders(1); + expect(firstHeaders?.get("Ai-trace-id")).toMatch(/^[0-9a-f-]{36}$/u); + expect(secondHeaders?.get("Ai-trace-id")).toMatch(/^[0-9a-f-]{36}$/u); + expect(secondHeaders?.get("Ai-trace-id")).not.toBe(firstHeaders?.get("Ai-trace-id")); + }); + it("uses the configured CN API region", async () => { postJsonRequestMock.mockResolvedValue({ response: { diff --git a/extensions/pixverse/video-generation-provider.ts b/extensions/pixverse/video-generation-provider.ts index 929e9a0699fc..1c80a10b0902 100644 --- a/extensions/pixverse/video-generation-provider.ts +++ b/extensions/pixverse/video-generation-provider.ts @@ -5,13 +5,12 @@ import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runt import { assertOkOrThrowHttpError, createProviderOperationDeadline, - createProviderOperationTimeoutResolver, - fetchProviderOperationResponse, + pollProviderOperationJson, postJsonRequest, postMultipartRequest, resolveProviderOperationTimeoutMs, resolveProviderHttpRequestConfig, - waitProviderOperationPollInterval, + sanitizeConfiguredModelProviderRequest, type ProviderOperationDeadline, } from "openclaw/plugin-sdk/provider-http"; import { asFiniteNumber, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -21,14 +20,15 @@ import type { VideoGenerationRequest, VideoGenerationSourceAsset, } from "openclaw/plugin-sdk/video-generation"; +import { + DEFAULT_PIXVERSE_MODEL_ID, + DEFAULT_PIXVERSE_REGION, + PIXVERSE_BASE_URL_BY_REGION, + PIXVERSE_PROVIDER_ID, + type PixVerseApiRegion, +} from "./constants.js"; -const PIXVERSE_BASE_URL_BY_REGION = { - international: "https://app-api.pixverse.ai/openapi/v2", - cn: "https://app-api.pixverseai.cn/openapi/v2", -} as const; -const DEFAULT_PIXVERSE_REGION = "international"; const DEFAULT_PIXVERSE_BASE_URL = PIXVERSE_BASE_URL_BY_REGION[DEFAULT_PIXVERSE_REGION]; -const DEFAULT_PIXVERSE_MODEL = "v6"; const DEFAULT_PIXVERSE_QUALITY = "540p"; const DEFAULT_TIMEOUT_MS = 300_000; const POLL_INTERVAL_MS = 5_000; @@ -47,8 +47,6 @@ const PIXVERSE_TEXT_ASPECT_RATIOS = [ ] as const; const PIXVERSE_QUALITIES = ["360p", "540p", "720p", "1080p"] as const; -type PixVerseApiRegion = keyof typeof PIXVERSE_BASE_URL_BY_REGION; - type PixVerseEnvelope = { ErrCode?: unknown; ErrMsg?: unknown; @@ -75,7 +73,7 @@ type PixVerseVideoResultResponse = { }; function resolvePixVerseBaseUrl(req: VideoGenerationRequest): string { - const provider = req.cfg?.models?.providers?.pixverse; + const provider = req.cfg?.models?.providers?.[PIXVERSE_PROVIDER_ID]; const configuredBaseUrl = normalizeOptionalString(provider?.baseUrl); if (configuredBaseUrl) { return configuredBaseUrl; @@ -104,7 +102,7 @@ function resolvePixVerseApiRegion(value: unknown): PixVerseApiRegion { function normalizePixVerseModel(model: string | undefined): string { const normalized = normalizeOptionalString(model)?.replace(/^pixverse\//iu, ""); - return normalized?.toLowerCase() || DEFAULT_PIXVERSE_MODEL; + return normalized?.toLowerCase() || DEFAULT_PIXVERSE_MODEL_ID; } function resolvePixVerseQuality(req: VideoGenerationRequest): string { @@ -142,19 +140,15 @@ function appendOptionalString(body: Record, key: string, value: } } -function buildHeaderEntries(apiKey: string, contentType?: string): Record { - const headers: Record = { - "API-KEY": apiKey, - "Ai-trace-id": randomUUID(), - }; +function buildPixVerseHeaders(headers: Headers, contentType?: string): Headers { + const next = new Headers(headers); + next.set("Ai-trace-id", randomUUID()); if (contentType) { - headers["Content-Type"] = contentType; + next.set("Content-Type", contentType); + } else { + next.delete("Content-Type"); } - return headers; -} - -function buildHeaders(apiKey: string, contentType?: string): Headers { - return new Headers(buildHeaderEntries(apiKey, contentType)); + return next; } function readPixVerseSuccess(payload: PixVerseEnvelope, label: string): T { @@ -285,44 +279,31 @@ function readPixVerseFailureMessage(payload: PixVerseVideoResultResponse): strin async function pollPixVerseVideo(params: { videoId: number; - apiKey: string; baseUrl: string; deadline: ProviderOperationDeadline; fetchFn: typeof fetch; + allowPrivateNetwork: boolean; + dispatcherPolicy?: Parameters[0]["dispatcherPolicy"]; + headers: Headers; }): Promise { - for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) { - const response = await fetchProviderOperationResponse({ - stage: "poll", - url: `${params.baseUrl}/video/result/${params.videoId}`, - init: { - method: "GET", - headers: buildHeaders(params.apiKey), - }, - timeoutMs: createProviderOperationTimeoutResolver({ - deadline: params.deadline, - defaultTimeoutMs: DEFAULT_TIMEOUT_MS, - }), - fetchFn: params.fetchFn, - provider: "pixverse", - requestFailedMessage: "PixVerse video status request failed", - }); - const payload = await readPixVerseJson( - response, - "PixVerse video status request failed", - ); - if (readPixVerseStatus(payload) === 1) { - return payload; - } - const failureMessage = readPixVerseFailureMessage(payload); - if (failureMessage) { - throw new Error(failureMessage); - } - await waitProviderOperationPollInterval({ - deadline: params.deadline, - pollIntervalMs: POLL_INTERVAL_MS, - }); - } - throw new Error(`PixVerse video generation task ${params.videoId} did not finish in time`); + const readResult = (payload: PixVerseEnvelope) => + readPixVerseSuccess(payload, "PixVerse video status request failed"); + const payload = await pollProviderOperationJson>({ + url: `${params.baseUrl}/video/result/${params.videoId}`, + headers: () => buildPixVerseHeaders(params.headers), + deadline: params.deadline, + defaultTimeoutMs: DEFAULT_TIMEOUT_MS, + fetchFn: params.fetchFn, + maxAttempts: MAX_POLL_ATTEMPTS, + pollIntervalMs: POLL_INTERVAL_MS, + requestFailedMessage: "PixVerse video status request failed", + timeoutMessage: `PixVerse video generation task ${params.videoId} did not finish in time`, + isComplete: (candidate) => readPixVerseStatus(readResult(candidate)) === 1, + getFailureMessage: (candidate) => readPixVerseFailureMessage(readResult(candidate)), + allowPrivateNetwork: params.allowPrivateNetwork, + dispatcherPolicy: params.dispatcherPolicy, + }); + return readResult(payload); } function extractPixVerseVideo(payload: PixVerseVideoResultResponse): GeneratedVideoAsset { @@ -344,14 +325,14 @@ function extractPixVerseVideo(payload: PixVerseVideoResultResponse): GeneratedVi export function buildPixVerseVideoGenerationProvider(): VideoGenerationProvider { return { - id: "pixverse", + id: PIXVERSE_PROVIDER_ID, label: "PixVerse", - defaultModel: DEFAULT_PIXVERSE_MODEL, + defaultModel: DEFAULT_PIXVERSE_MODEL_ID, defaultTimeoutMs: DEFAULT_TIMEOUT_MS, models: [...PIXVERSE_VIDEO_MODELS], isConfigured: ({ agentDir }) => isProviderApiKeyConfigured({ - provider: "pixverse", + provider: PIXVERSE_PROVIDER_ID, agentDir, }), capabilities: { @@ -416,7 +397,7 @@ export function buildPixVerseVideoGenerationProvider(): VideoGenerationProvider } const auth = await resolveApiKeyForProvider({ - provider: "pixverse", + provider: PIXVERSE_PROVIDER_ID, cfg: req.cfg, agentDir: req.agentDir, store: req.authStore, @@ -427,6 +408,7 @@ export function buildPixVerseVideoGenerationProvider(): VideoGenerationProvider const model = normalizePixVerseModel(req.model); const fetchFn = fetch; + const providerConfig = req.cfg?.models?.providers?.[PIXVERSE_PROVIDER_ID]; const deadline = createProviderOperationDeadline({ timeoutMs: req.timeoutMs, label: "PixVerse video generation", @@ -435,8 +417,11 @@ export function buildPixVerseVideoGenerationProvider(): VideoGenerationProvider resolveProviderHttpRequestConfig({ baseUrl: resolvePixVerseBaseUrl(req), defaultBaseUrl: DEFAULT_PIXVERSE_BASE_URL, - defaultHeaders: buildHeaderEntries(auth.apiKey, "application/json"), - provider: "pixverse", + request: sanitizeConfiguredModelProviderRequest(providerConfig?.request), + defaultHeaders: { + "API-KEY": auth.apiKey, + }, + provider: PIXVERSE_PROVIDER_ID, capability: "video", transport: "http", }); @@ -446,7 +431,7 @@ export function buildPixVerseVideoGenerationProvider(): VideoGenerationProvider if (image) { const upload = await postMultipartRequest({ url: `${baseUrl}/image/upload`, - headers: buildHeaders(auth.apiKey), + headers: buildPixVerseHeaders(headers), body: buildUploadImageForm(image), timeoutMs: resolveProviderOperationTimeoutMs({ deadline, @@ -472,7 +457,7 @@ export function buildPixVerseVideoGenerationProvider(): VideoGenerationProvider const endpoint = imageId === undefined ? "/video/text/generate" : "/video/img/generate"; const create = await postJsonRequest({ url: `${baseUrl}${endpoint}`, - headers, + headers: buildPixVerseHeaders(headers, "application/json"), body: buildVideoBody(req, model, imageId), timeoutMs: resolveProviderOperationTimeoutMs({ deadline, @@ -492,10 +477,12 @@ export function buildPixVerseVideoGenerationProvider(): VideoGenerationProvider ); const completed = await pollPixVerseVideo({ videoId, - apiKey: auth.apiKey, baseUrl, deadline, fetchFn, + allowPrivateNetwork, + dispatcherPolicy, + headers, }); return { videos: [extractPixVerseVideo(completed)], diff --git a/package.json b/package.json index e41e1a14288f..64faec9be174 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "!dist/extensions/nextcloud-talk/**", "!dist/extensions/nostr/**", "!dist/extensions/qqbot/**", + "!dist/extensions/pixverse/**", "!dist/extensions/qa-channel/**", "!dist/extensions/qa-lab/**", "!dist/extensions/qa-matrix/**", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7df61d748d7c..be6729e42b63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1239,6 +1239,9 @@ importers: '@openclaw/plugin-sdk': specifier: workspace:* version: link:../../packages/plugin-sdk + openclaw: + specifier: workspace:* + version: link:../.. extensions/policy: dependencies: diff --git a/scripts/generate-plugin-inventory-doc.mjs b/scripts/generate-plugin-inventory-doc.mjs index 95cfc9ccb669..ce01f6cb748e 100644 --- a/scripts/generate-plugin-inventory-doc.mjs +++ b/scripts/generate-plugin-inventory-doc.mjs @@ -114,6 +114,7 @@ function humanizeId(value) { ["opencode", "OpenCode"], ["openrouter", "OpenRouter"], ["otel", "OpenTelemetry"], + ["pixverse", "PixVerse"], ["qa", "QA"], ["qqbot", "QQ Bot"], ["qwen", "Qwen"], diff --git a/scripts/lib/official-external-provider-catalog.json b/scripts/lib/official-external-provider-catalog.json index 8fc0130f41f9..45a2ddbf8fc6 100644 --- a/scripts/lib/official-external-provider-catalog.json +++ b/scripts/lib/official-external-provider-catalog.json @@ -112,6 +112,47 @@ "minHostVersion": ">=2026.5.1-beta.1" } } + }, + { + "name": "@openclaw/pixverse-provider", + "description": "OpenClaw PixVerse video provider plugin", + "source": "official", + "kind": "provider", + "openclaw": { + "plugin": { + "id": "pixverse", + "label": "PixVerse" + }, + "providers": [ + { + "id": "pixverse", + "name": "PixVerse", + "docs": "/providers/pixverse", + "categories": ["cloud", "video"], + "authChoices": [ + { + "method": "api-key", + "choiceId": "pixverse-api-key", + "choiceLabel": "PixVerse API key", + "choiceHint": "Wizard prompts for International or CN endpoint.", + "groupId": "pixverse", + "groupLabel": "PixVerse", + "groupHint": "Video generation", + "optionKey": "pixverseApiKey", + "cliFlag": "--pixverse-api-key", + "cliOption": "--pixverse-api-key ", + "cliDescription": "PixVerse API key", + "onboardingScopes": ["image-generation"] + } + ] + } + ], + "install": { + "npmSpec": "@openclaw/pixverse-provider", + "defaultChoice": "npm", + "minHostVersion": ">=2026.5.26" + } + } } ] } diff --git a/src/media-generation/runtime-shared.test.ts b/src/media-generation/runtime-shared.test.ts index 74b5e07ced57..a4c9223d6d8d 100644 --- a/src/media-generation/runtime-shared.test.ts +++ b/src/media-generation/runtime-shared.test.ts @@ -278,6 +278,16 @@ describe("media-generation runtime shared normalization", () => { ).toBe("540P"); }); + it("does not map across image and video resolution units", () => { + expect( + resolveClosestResolution({ + requestedResolution: "4K", + supportedResolutions: ["768P", "1080P"], + order: ["360P", "480P", "540P", "720P", "768P", "1080P"], + }), + ).toBeUndefined(); + }); + it("clamps durations to the closest supported max", () => { expect(normalizeDurationToClosestMax(12, 8)).toBe(8); expect(normalizeDurationToClosestMax(6, 8)).toBe(6); diff --git a/src/media-generation/runtime-shared.ts b/src/media-generation/runtime-shared.ts index 64937affe043..a7522f7149c4 100644 --- a/src/media-generation/runtime-shared.ts +++ b/src/media-generation/runtime-shared.ts @@ -466,17 +466,17 @@ export function resolveClosestResolution(params: { return params.requestedResolution; } const requestedNumeric = parseResolutionRank(params.requestedResolution); - if (typeof requestedNumeric === "number") { + if (requestedNumeric) { let bestValue: TResolution | undefined; let bestScore: { primary: number; secondary: number; tertiary: string } | null = null; for (const candidate of supported) { const candidateNumeric = parseResolutionRank(candidate); - if (typeof candidateNumeric !== "number") { + if (!candidateNumeric || candidateNumeric.unit !== requestedNumeric.unit) { continue; } const score = { - primary: Math.abs(candidateNumeric - requestedNumeric), - secondary: candidateNumeric < requestedNumeric ? 1 : 0, + primary: Math.abs(candidateNumeric.value - requestedNumeric.value), + secondary: candidateNumeric.value < requestedNumeric.value ? 1 : 0, tertiary: candidate, }; if (compareScores(score, bestScore)) { @@ -516,7 +516,9 @@ export function resolveClosestResolution(params: { return bestValue; } -function parseResolutionRank(resolution: string | undefined): number | undefined { +function parseResolutionRank( + resolution: string | undefined, +): { value: number; unit: "K" | "P" } | undefined { const match = resolution?.trim().match(/^(\d+(?:\.\d+)?)([kp])$/iu); if (!match) { return undefined; @@ -525,7 +527,11 @@ function parseResolutionRank(resolution: string | undefined): number | undefined if (!Number.isFinite(value)) { return undefined; } - return match[2]?.toUpperCase() === "K" ? value * 1000 : value; + const unit = match[2]?.toUpperCase() === "K" ? "K" : "P"; + return { + value: unit === "K" ? value * 1000 : value, + unit, + }; } export function normalizeDurationToClosestMax( diff --git a/src/media-understanding/shared.ts b/src/media-understanding/shared.ts index a77f4801c315..0e7df13dc0b6 100644 --- a/src/media-understanding/shared.ts +++ b/src/media-understanding/shared.ts @@ -150,7 +150,7 @@ export async function waitProviderOperationPollInterval(params: { export async function pollProviderOperationJson( params: { url: string; - headers: Headers; + headers: Headers | (() => Headers); deadline: ProviderOperationDeadline; defaultTimeoutMs: number; fetchFn: typeof fetch; @@ -165,7 +165,7 @@ export async function pollProviderOperationJson( for (let attempt = 0; attempt < params.maxAttempts; attempt += 1) { const init = { method: "GET", - headers: params.headers, + headers: typeof params.headers === "function" ? params.headers() : params.headers, }; const timeoutMs = createProviderOperationTimeoutResolver({ deadline: params.deadline, diff --git a/src/plugin-sdk/test-helpers/provider-http-mocks.ts b/src/plugin-sdk/test-helpers/provider-http-mocks.ts index bfc92e074355..0692b629e990 100644 --- a/src/plugin-sdk/test-helpers/provider-http-mocks.ts +++ b/src/plugin-sdk/test-helpers/provider-http-mocks.ts @@ -177,11 +177,12 @@ providerHttpMocks.fetchProviderDownloadResponseMock.mockImplementation( providerHttpMocks.pollProviderOperationJsonMock.mockImplementation( async (params: PollProviderOperationJsonParams) => { for (let attempt = 0; attempt < params.maxAttempts; attempt += 1) { + const headers = typeof params.headers === "function" ? params.headers() : params.headers; const response = await providerHttpMocks.fetchWithTimeoutMock( params.url, { method: "GET", - headers: params.headers, + headers, }, params.defaultTimeoutMs, params.fetchFn,